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
1declare function table(items: any[], fieldOptions: any[]): void;-
We have a function that accepts some collection of
itemsand a collection offieldOptionsthat should be strongly typed to the type of each individualitems1declare function table(items: any[], fieldOptions: any[]): void;23const items = [4{5foo: "some foo",6bar: 123,7},8{9foo: "some foo two",10bar: 456,11},12]; -
From this usage,
itemshas a type ofArray<{foo: string, bar: number}>andfieldOptionsneeds to be strongly typed against{foo: string, bar: number}. Usage oftable()can be as follow1const items = [2{3foo: "some foo",4bar: 123,5},6{7foo: "some foo two",8bar: 456,9},10];1112table(items, [13"foo",14{15field: "bar",16mapFn: (val) => {17// should return something. eg: a string18},19},20]);Here, we can see that
fieldOptionscan accept each key of{foo: string, bar: number}, aka'foo' | 'bar'. In addition,fieldOptionscan also accept aFieldOptionobject that has afield: 'foo' | 'bar'as well asmapFncallback that will be invoked with the value at{foo: string, bar: number}[key]. In other words, when we passfield: "bar",mapFnthen needs to have type:(value: number) => stringbecausebarhasnumberas 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 initemscollection1declare function table(items: any[], fieldOptions: any[]): void;2declare function table<TItem>(items: TItem[], fieldOptions: any[]): void;In addition, we also like to constraint
TItemto anobjectso the consumers can only pass in a collection of objects1declare function table<TItem>(items: TItem[], fieldOptions: any[]): void;2declare function table<TItem extends Record<string, unknown>>(items: TItem[], fieldOptions: any[]): void;TItem 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 keys1type FieldOption<TObject extends Record<string, unknown>> = keyof TObject | {2field: keyof TObject;3mapFn: (value: TObject[keyof TObject]) => string;4}56declare function table<TItem extends Record<string, unknown>>(7items: TItem[],8fieldOptions: any[],9fieldOptions: FieldOption<TItem>[]10): void;With the above type declaration,
FieldOption<{foo: string, bar: number}is as follow1type Test = FieldOption<{ foo: string; bar: number }>;2// ^? 'foo' |3// 'bar' |4// {5// field: 'foo' | 'bar';6// mapFn: (value: string | number) => string;7// }8// -
This seems correct but when we apply it, we find that the type isn’t as strict as we like
1const items = [{ foo: "some foo", bar: 123 }];23table(items, [4{5field: "foo",6mapFn: (value) => {7// ^? value: string | number8},9},10]);We like for
valueto have type ofstringinstead of a unionstring | numberbecause we specify the"foo"forfield. Hence,{foo: string, bar: number}['foo']should bestring -
The problem is that TypeScript cannot narrow down
mapFnfromfieldbecause we cannot constraint them the way we currently have our type. Our next step is to try havingkeyof TItemas a generic as well hoping that TypeScript can infer it fromfield1type FieldOption<2TItem extends Record<string, unknown>,3TKey extends keyof TItem = keyof TItem,4> =5| TKey6| {7field: TKey;8mapFn: (value: TKey) => string;9};But it still won’t work because we can never pass a generic in for
TKeyandTKeyis always defaulted tokeyof TItemwhich 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
1// let's assume we're working with {foo: string, bar: number} for now instead of a generic to simplify the explanation2
3// what we think we need4type FieldOption = {5 field: "foo" | "bar";6 mapFn: (value: string | number) => string;7};8
9// what we actually need10type FieldOption =11 | {12 field: "foo";13 mapFn: (value: string) => string;14 }15 | {16 field: "bar";17 mapFn: (value: number) => string;18 };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
TIteminto a Mapped Type
1type FieldOption<TItem extends Record<string, unknown>> = {2 [TField in keyof TItem]: {3 field: TField;4 mapFn: (value: TItem[TField]) => string;5 };6};7
8type Test = FieldOption<{ foo: string; bar: number }>;9// ^? {10// foo: { field: 'foo'; mapFn: (value: string) => string };11// bar: { field: 'bar'; mapFn: (value: number) => string };12// }- We need to map over the Mapped Type with keys of
TItemto get the Mapped Union
1type FieldOption<TItem extends Record<string, unknown>> = {2 [TField in keyof TItem]: {3 field: TField;4 mapFn: (value: TItem[TField]) => string;5 };6}[keyof TItem];7
8type Test = FieldOption<{ foo: string; bar: number }>;9// ^? | { field: 'foo'; mapFn: (value: string) => string }10// | { field: 'bar'; mapFn: (value: number) => string }Now, let’s try using table() with our Mapped Union type to see if it works
1type FieldOption<TItem extends Record<string, unknown>> =2 | keyof TItem3 | {4 [TField in keyof TItem]: {5 field: TField;6 mapFn: (value: TITem[TField]) => string;7 };8 }[keyof TItem];9
10declare function table<TItem extends Record<string, unknown>>(11 items: TTem[],12 fields: FieldOption<TItem>[],13): void;14
15const items = [16 { foo: "string", bar: 123 },17 { foo: "string2", bar: 1234 },18];19
20table(items, [21 {22 field: "foo",23 mapFn: (value) => {24 // ^? value: string25 return "";26 },27 },28 {29 field: "bar",30 mapFn: (value) => {31 // ^? value: number32 return "";33 },34 },35]);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.