Dynamic Argument Support with Conditional Types
We're working with a
translate function that needs to capture translations dynamically. The keys need to be strongly typed, and we need to figure out the type of arguments based on the specific key provided.
Here's what we're starting with:
const translate = (translations: unknown
00:00 Okay, let's take a look at this. We know that this translate function, we're going to need to capture the translations, right? Because you can pass in any translation so they can be dynamic. So let's take in ttranslations, which is going to extend record string string. Because that record string string, that's the shape that these translations are in, right?
00:18 You can't have a number in this position, can't have a number in this position. So let's stick that in translations, ttranslations. Now, this key, we want to strongly type this from the outside. So let's make this key of ttranslations. And we should see that when we hover over translate here, we're getting this stuff properly inferred
00:38 and the key should be strongly typed. Yep, button subtitle. And if we pass in something random, then it's erring at us. Grand, okay. So now we need to figure out the type of args here. And args is going to be pretty special because we first of all need to grab the type
00:56 of the specific thing that we're passing in via the key. We need to know that when we specify title, that's the literal that's being inferred here. So let's do that first. Let's just say that for instance, how are we going to do this? Okay, let's do it by saying key. Like, first of all, key of ttranslations
01:15 is not going to cut it because that is a union of all possible members. When we call this function, we're going to need to know which specific key they are using in order to translate it properly. So let's actually put it in a type argument. So tkey is going to extend key of ttranslations.
01:32 And now let's stick that in the key, tkey, beautiful. And now when we hover over translate here, we are seeing it like being inferred properly there. So we have our button. And if we, are we still getting this? Yeah, okay, we're still getting that nice inference there too.
01:49 Now, what we want to do is we want to figure out these args because these args, depending on whether the translation selected has a dynamic signature or not, we want to basically say, okay, parse in an object containing those parameters or don't.
02:10 And this is a tricky one. So the first thing I do when I'm approaching a kind of complex problem, like inside a generic signature, is I do something like this, where we have, let's say we have a tconsole log here. And tconsole log, we can actually put anything in here. And when we kind of hover over this,
02:28 you can see that it's being kind of like logged out in the signature here. So what we can do is we can say, let's first of all just say, okay, ttranslations tkey. And that ttranslations tkey, that's going to tell us which of these messages is being inferred as the right message. So now we can see that when we parse in button,
02:46 we get click me here. And if I change this to subtitle instead, then we're going to get that instead. You have count unread messages. Beautiful. Okay. That's a good start. Let's now see what happens when we call get param keys on that message here.
03:05 So again, let's take a look. So button now is an empty tuple here. And that empty tuple is because there's no dynamic stuff inside button. Whereas on subtitle, you can see that count is being inferred here. Nice. Okay. So that console log is helping us out there, but now we need to figure out these args.
03:24 Well, we've already seen that you can do a trick like this, where if you say string extends string, you can basically have two branches here of a conditional type where you can either say, okay, we've got like params. And let's say params is going to be, we'll figure out the shape of this in a second,
03:42 but let's just say record string string. And now what's happening here is because string does extend string, we have to pass in a third arguments here of params into this. It's expects three arguments, but got two. And here we're seeing params is a record string string, although you can't see that. So that's good.
04:01 But now how do we like figure out this logic based on whether, based on the result of this get param keys. And let's actually just extract this out into its own, kind of like inline this here. So what we wanna say is if get param keys, basically, if it's an empty object,
04:21 then we don't want to, or sorry, an empty tuple, then we don't want the user to pass in any arguments there. So we don't want any arguments in that slot there. But if it does, right, then we want to, if it does have more than one key, then we want to get them to pass in a record here.
04:40 And that record has to be, we can see all the tests are passing here, but this record has to be like count, right? We can't just pass in anything there. So first of all, so this seems like it's working pretty well actually, because get param keys, if we just extract this out into a log up here, so log equals this, we know that when it's button,
05:01 then it's going to basically just be empty array. And if it's something with more than that, then it's going to be in there too. Really, really nice. But now how do we type this here, the key of the params thing that we're requesting? Well, we can say get param keys. This is just a standard,
05:19 like, because we're just transforming an array into a union, right? So we now got ttranslations key number, and just grab the number in there and stick it in there. So now it's working. And count, we should see that count string is now auto-completed here.
05:38 And if I change this subtitle, if I change it to message count, then that is going to error down there, and it's going to give me all those beautiful, beautiful errors and wonderful, wonderful red lines. Nice. Now there's a little bit of duplication happening here, where we have get param keys is kind of being inferred in two places or used in two places.
05:59 And this is quite an expensive computation. We're having to basically go through, check this entire string and create an array out of it. And we're having to do that work twice. Wouldn't it be great if there was just a way that we could just do it once and have it just work? Well, we can do that by extracting it up actually into the parameters here.
06:18 When you declare these parameters, you can actually do computation inside them. And it means that you can do, let's say, tparamkeys. And let's say we just go, okay, get param keys, stick it in there, just like we did with our log earlier. Now tparamkeys is actually defined
06:39 across the scope of this translate signature. So we can now, instead of having get param keys, we can say tparamkeys. Now there's one problem here, and this still works by the way. So everything's actually working down here. It's just an error inside the function, which we'll get to briefly. You can see now we even get the little log readout here,
06:57 which is lovely. So now this is one more issue, which is the tparamkeys type number cannot be used at index type tparamkeys. Well, that's because tparamkeys, we're not actually constraining it to be anything, even though it's kind of like, in fact, we could just do this, where we could just say extends tparamkeys,
07:17 except there seems to be an issue there. We'll take a brief look at that in a second. But the main way that I think of doing this is just to say, basically, we can just say it extends an array of strings here. And what we're saying here is tparamkeys, it's got a sort of vague shape that we know about, which is the return value of get tparamkeys.
07:37 And when we call the function, it gets instantiated with this nice little thing here. And now because we've got this constraint here, the stuff inside the function is happy too. So it's funny, isn't it? Like all of these constraints are there for different reasons. This one is because, like to affect the outer scope of the function.
07:57 This one is actually here to affect the inner scope of the function and to make sure that it's all working properly. And this default doesn't really have any impact on the kind of outside scope of the function. It's just to basically save us a bit of work when we're inside the function. So we don't have to compute tparamkeys twice.
08:14 So much depends on these like little sort of tricks and like these little optimizations that you can make. But this I think is a really, really clean solution and one that I'd be happy to put in production. And if you found this, well done. There's a lot going on here. Let's actually just recap briefly. We've got this ttranslations.
08:34 We're capturing the ttranslations and the key inside the signature. Then we're doing this computation where we say, okay, we check if tparamkeys extends an empty object. If it does, we don't require any args. But if it does, then we just pull in params here
08:51 and we create a record out of the tparamkeys number. And then we force the user to pass that. If you found that, great work.