An unnecessary complexity tale on Typescript-land.

zenobio

Zenobio

Posted on April 2, 2022

An unnecessary complexity tale on Typescript-land.

Twitter is both a blessing and a curse as anyone who's fond of the platform can attest to. Today, I've found a small challenge lurking within a tweet under a hashtag that I don't really follow or have any interest whatsoever but sometimes it pops up (???)...

The challenger was really straight forward:

oleg008 Tweet

As someone who likes to keep the codebase with the small amount of any's possible, I clicked the playground link and gave it a try.

And... are those "Generics" with us in the room right now?

My first idea was pretty simple:

const f = <T extends Container<unknown>>(
  containers: T[],
  callback: (...values: any) => void
) => { ... }
Enter fullscreen mode Exit fullscreen mode

Starting with a Generic T which needs to extends the Container type to guarantee that the containers parameter type is undoubtedly a List of Containers.

Now, how do we implement the type logic for ...values within the callback parameter? After some time of weird thinking and listening to some City Pop, my "amazing" head came up with this:

type FromList<L> = L extends Container<infer T>[] 
  ? L extends [Container<infer H>, ...infer Tail] 
    ? H | FromList<Tail> 
    : T 
  : never;

const f = <T extends Container<unknown>>(
  containers: T[],
  callback: (...values: FromList<typeof containers>[]) => void
) => { ... }
Enter fullscreen mode Exit fullscreen mode

I know, I know, a fully fledged display of unnecessary complexity. That's exactly why I said "amazing"...

This FromList type says that, given a List of type List of Containers of something, infer the type of this something. The second validation says that, for this same given type List of Containers of something destructure it as a Tuple.

Since tuples are only lists with fixed size and known types (Weirdly close to a indexed type, don't you think so?), you can ask for this type expansion to infer the type of the first something within the Container type at the first index of this tuple while also using the spread operator to create a Tail with the remaining types.

If the first validation fails, it will return a never, a type that could not be constructed. In case it succeeds we enter the second validation. For the second validation if it fails we will just return the type of the first validation because this isn't a "tuple" it's exactly a List of Container of something and we just want this something to be our type. In case it succeeds, we will recursively create a Union which consists of the inferred type of the first something or the reduced type of subsequent FromList called with the inferred Tail type.

That's not quite right, isn't it?

If you have spent some time playing with those custom utility types in TS then you're probably aware of the problem already.

Adding this same logic to the playground we can see that first of all, TS is not happy. He's saying;

const values: unknown[]
Argument of type 'unknown' is not assignable to parameter of type 'FromList<T[]>'.
Enter fullscreen mode Exit fullscreen mode

That's because we didn't gave a type to the values array which is being filled and used as parameter for our callback function. And since we typed our containers parameter as T[] and T is extending a Container of unknown, we pretty much have no way to type it correctly without using type coercions which I tend to only use on cases where I have no alternatives.

The second problem is that we're returning a Union type from our FromList and that will be the type of each and every single one of our parameters within the callback.

// value1 is of type string | number
// value2 is of type string | number
// Any value within the callback will be this union type...
f([container1, container2], (value1, value2) => {
  ...
})
Enter fullscreen mode Exit fullscreen mode

We need a way to know exactly which are the types that are being held by every Container in every index of our containers parameter.

Removing the Complexity

First I want to mention that even though I've spent some time and have also used the destructured tuple in my first complex idea, I wasn't really able to move forward from there.

After this Union type I knew that an indexed type was necessary so it would be easier to retrieve the something within each Container for the given key but I wasn't able to translate it into the correct idea. Lack of experience in a case like that I assume.

I went back to the same tweet to check how people where implementing or at least how they where thinking about the problem. Found some interesting points of view but the one that caught my attention was this one from devanshj__.

Mainly because of this line;

// Changing the nomenclature for consistency
declare const f:
  <T extends Container<unknown>[]>
    ( containers: [...T],
      ...) => { ... }
Enter fullscreen mode Exit fullscreen mode

If you destructure the T as a tuple as the type for the containers parameter, TS will be able to infer which are the types in each and every index of it.

Who knows why but my head wasn't capable of thinking in use the tuple this way. So, I went and updated my utility custom type to the same of the above example.

type PickFromContainer<C> = {[K in keyof C]: C[K] extends Container<infer CT> ? CT : never};

const f = <T extends Container<unknown>[]>(
    containers: [...T],
    callback: (...values: PickFromContainer<T>) => void
) => { ... }
Enter fullscreen mode Exit fullscreen mode

This PickFromContainer type says; given a type C (our tuple), for each of their keys (indexes), we will check if the type in that index is of type Container of Something and if it is, we will ask TS to infer this something for us and return it as the type within the index being mapped.

That's beautiful and works like a charm. If you place that on the playground you'll see that every param on the callback function is the same type something as the Container of something in that position based on the containers parameter order.

But wait... Typescript is screaming at me again?

Argument of type 'unknown[]' is not assignable to parameter of type 'PickFromContainer<T>'
Enter fullscreen mode Exit fullscreen mode

Yeah, pretty much the same, since our type T is necessarily a List of Containers of Unknown and we're pushing a bunch of Unknown to values, it's only fair that TS would consider the type of values as a List of Unknown and when we call our callback function with this list as a parameter, the PickFromContainer will not overlap the List of Unknown.

The only two things that can be assigned to unknown are any and unknown itself and that leaves us with two ways to make TS happy right now.

  1. Be rude and say: "I don't care, it can be anything!" and Coerce the values list to any.
  2. Be kind and say: "Hey Typescript, I know that values is unknown to you but I also know that it will be a PickFromContainer when needed".

From the start my idea was to remove any's so I'm going with the second approach.

const f = <T extends Container<unknown>[]>(
    containers: [...T],
    callback: (...values: PickFromContainer<T>) => void
) => {
    const values = [] as unknown as PickFromContainer<T>;
    for (const container of containers) {
        values.push(container.value)
    }
    callback(...values)
}
Enter fullscreen mode Exit fullscreen mode

That's not my favorite approach, as mentioned before. We could have used a different approach with type predicates and let the compiled part of JS optimize it on its own but let's keep it simple and tell TS to believe us in this case. Here's the playground.

Typescript doesn't really need to be complicated - Even though it is in some cases. Sometimes we add unnecessary complexity to things and everyone have done it at least once.

Look at other developers ideas and their implementations is a really good way to improve yours. Just don't copy and paste everything, try to understand the why's and when's, because that how you'll be able to improve and derive from it when necessary.

Edit: It has been quite some time since I last saw this code and I did tried one or two extra tricks that I've learned throughout the way and it was enough to get rid of the type casting at the end. Please feel free to take a look on how I would implement the same thing today. Here's the playground link.

💖 💪 🙅 🚩
zenobio
Zenobio

Posted on April 2, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related