An unnecessary complexity tale on Typescript-land.
Zenobio
Posted on April 2, 2022
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:
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
) => { ... }
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
) => { ... }
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[]>'.
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) => {
...
})
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],
...) => { ... }
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
) => { ... }
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>'
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.
- Be rude and say: "I don't care, it can be anything!" and Coerce the
values
list toany
. - Be kind and say: "Hey Typescript, I know that
values
isunknown
to you but I also know that it will be aPickFromContainer
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)
}
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.
Posted on April 2, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.