Some funky parts of Typescript

Conrad Holtzhausen - Feb 21 '23 - - Dev Community

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:

  1. First I will take you down the rabbit hole we went on together in the session until we ran out of time.
  2. Secondly, I will take you deeper down the same rabbit hole I went on alone the day after.
  3. I will then finally share the solution that I shamelessly looked at after being thoroughly defeated by the challenge
  4. 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 be Promise<T> where T 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]>
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

Let’s break this down:

  1. Introduce a generic T with a any[] constraint that we can manipulate
  2. Assign values to T and wrap the result of T in our custom AwaitedPromiseAllResult type
  3. 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>];
Enter fullscreen mode Exit fullscreen mode

When testing our AwaitedPromiseAllResult type separately it seemed to work:

Result when testing AwaitedPromiseAllResult in isolation

but when using the type in the function we get a whole different result… why, Why, WHY 😡

Result when testing PromiseAll function

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]): ...;
Enter fullscreen mode Exit fullscreen mode

Spread type as a tuple

Yes

Awesome that works, but what happens if I override the generic constraint with something else?

Pass generic override

Flipping table

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
}
Enter fullscreen mode Exit fullscreen mode

demo of MapArrayAsObjectToIndexes result

WTF

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]>
}> 
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . .