Recently, a coworker at Nx approaches me with a TypeScript problem that we both thought “It seems simple” at first. We soon find that it’s not as simple as we thought. In this blog post, I’ll walk you through the problem, the seems to be solution, the solution, and the thought process behind them.
The Problem
-
We have a function that accepts some collection of
items
and a collection offieldOptions
that should be strongly typed to the type of each individualitems
-
From this usage,
items
has a type ofArray<{foo: string, bar: number}>
andfieldOptions
needs to be strongly typed against{foo: string, bar: number}
. Usage oftable()
can be as followHere, we can see that
fieldOptions
can accept each key of{foo: string, bar: number}
, aka'foo' | 'bar'
. In addition,fieldOptions
can also accept aFieldOption
object that has afield: 'foo' | 'bar'
as well asmapFn
callback that will be invoked with the value at{foo: string, bar: number}[key]
. In other words, when we passfield: "bar"
,mapFn
then needs to have type:(value: number) => string
becausebar
hasnumber
as its valuet type.
The “seems to be” Solution
At first glance, it seems easy. We’ll go through each step.
-
First,
table()
needs to accept a generic to capture the type of each item initems
collectionIn addition, we also like to constraint
TItem
to anobject
so the consumers can only pass in a collection of objectsTItem extends Record<string, unknown>
is the constraint -
Second, we need a type for
fieldOptions
. This type needs to accept a generic that is an object so that we can iterate through the object keysWith the above type declaration,
FieldOption<{foo: string, bar: number}
is as follow -
This seems correct but when we apply it, we find that the type isn’t as strict as we like
We like for
value
to have type ofstring
instead of a unionstring | number
because we specify the"foo"
forfield
. Hence,{foo: string, bar: number}['foo']
should bestring
-
The problem is that TypeScript cannot narrow down
mapFn
fromfield
because we cannot constraint them the way we currently have our type. Our next step is to try havingkeyof TItem
as a generic as well hoping that TypeScript can infer it fromfield
But it still won’t work because we can never pass a generic in for
TKey
andTKey
is always defaulted tokeyof TItem
which will always be'foo' | 'bar'
for our{foo: string, bar: number}
item type. So as of this moment, we’re stuck.We spent a good 15-20 minutes trying things out but to no avail.
The Solution
Out of frustration, I asked for help in trashh_dev Discord and Tom Lienard provided the solution with a super neat trick. I’ll attempt to go through the thought process to understand the solution
What we’re stuck on is we’re so hung up on the idea of FieldOption
needs to be an object type {field: keyof TItem, mapFn}
but in reality, what we actually need is as follow
Yes, we need a Mapped Union from our TItem
instead of a single object with union properties. The question is how we
get to the Mapped Union. Well, it is a 2-step process
- We need to convert
TItem
into a Mapped Type
- We need to map over the Mapped Type with keys of
TItem
to get the Mapped Union
Now, let’s try using table()
with our Mapped Union type to see if it works
And that is our solution. So simple, yet so powerful trick. Here’s the TypeScript Playground that you can play with.
If anyone knows the correct term for Mapped Union, please do let me know so I can update the blog post.