And by funky I don’t mean like the smell of chunky milk. I mean funky like the sweet music 💃.
At Hotjar, we have bi-weekly Typescript pair programming sessions where we try and solve a few of the type challenges together. In one session after solving the challenges we set out to, we chose a random challenge as a last hurrah and boy was it a doozy.
So why are you reading this? Well let me tell you what you can expect if you take the red pill with this TL;DR:
- First I will take you down the rabbit hole we went on together in the session until we ran out of time.
- Secondly, I will take you deeper down the same rabbit hole I went on alone the day after.
- I will then finally share the solution that I shamelessly looked at after being thoroughly defeated by the challenge
- The main reason I share all this with you is that it contains some interesting nuggets on how the type system works underneath.
The challenge
As I mentioned already, we chose to do the Promise.all challenge. Basically this:
Type the function
PromiseAll
that accepts an array of PromiseLike objects, the returning value should bePromise<T>
whereT
is the resolved result array.
so:
// when passing this array
PromiseAll([Promise.resolve(3), 42]);
// PromiseAll's return type should be this
Promise<[number, 42]>
Recursion
Our first thought was to recursively go through the passed array and return the awaited value of each for the result. Something like this:
declare function PromiseAll<T extends readonly any[]>(values: T): Promise<AwaitedPromiseAllResult<T>>;
Let’s break this down:
- Introduce a generic
T
with aany[]
constraint that we can manipulate - Assign
values
toT
and wrap the result ofT
in our customAwaitedPromiseAllResult
type - Within
AwaitedPromiseAllResult
we do the following
// spread T so we can get the first item in the array and the rest
type AwaitedPromiseAllResult<T extends readonly any[]> = T extends [infer TFirst, ...infer TRest]
// Unwrap the promise on the first item and repeat this process for the rest
? [Awaited<TFirst>, ...AwaitedPromiseAllResult<TRest>]
// when there is only one item left in the array, unwrap it and you are done
: [...Awaited<T>];
When testing our AwaitedPromiseAllResult
type separately it seemed to work:
but when using the type in the function we get a whole different result… why, Why, WHY 😡
Here is why:
Note that we constrain our generic as an any[]
and then assign our values
param as that. In other words [1, 2, Promise<3>]
would become number | Promise<number>[]
.
Okay, that makes sense. So what if we spread the any[]
as the values
type instead? Well 🙂...
declare function PromiseAll<T ...>(values: [...T]): ...;
Awesome that works, but what happens if I override the generic constraint with something else?
The solution
This is where I gave up and looked at the solutions. All of them were basically a different flavour of the same thing. But before I just show you the solution allow me to stretch this post unnecessarily out a little more.
We’ve come to the reason that I was implored to share this with you all. As you’ve seen in the previous section, by spreading an any[]
type you get a tuple. Which is an array of known types. And as you might know, an array is an object. Why is this important? Because Typescript is smart enough to treat it as such and allow you to map over the indexes of an array like you would the properties of a key/value pair object 🤯.
Or in code words… basically this:
type MapArrayAsObjectToIndexes<T extends any[]> = {
[TKey in keyof T]: TKey
}
Yup 🙂
And finally the solution:
// as before use a generic `T` and convert it to a tuple for `values`'s type
declare function PromiseAll<T extends any[]>(values: readonly [...T]): Promise<{
// map over the indexes of the array and return their unwrapped values
[k in keyof T]: Awaited<T[k]>
}>
Conclusion
Typescript is fun to play with. And I know a lot of people might have already known all the things that I wrote about, but if there were only one or two people who found this interesting I will feel satisfied. But the bigger reason I shared this is to encourage you to play around. Understanding how Typescript's type system works has greatly benefited our day-to-day work even though we don't implement things in this way directly. So have fun and keep learning.