Recursing and transforming tuples in Typescript
I have recently come across a problem on trying to provide an intuitive typing for an array of objects that represents a configuration, rather than data. So, every configuration can have different type requirements.
In Typescript, tuple is an array where the number of items is know beforehand and the each item can have different types. This concept is exactly what I was looking for but there was one catch -- I still wanted to retain some form of consistency among items:
interface ItemConfig<T> {
/**
* List of items
*/
data: T[];
/**
* Updater function
*/
update(point: T) => void;
}
Every item config object has data and an update function but the type of data that they accept is defined by the generic. I can already use this type to create my array:
const config: [ItemConfig<number>, ItemConfig<string>] = [
{
data: [1, 2, 3, 4, 5],
update(point: number) {
console.log(point);
},
},
{
data: ["a", "b", "c", "d"],
update(point: string) {
console.log(point);
},
},
];
This works well but it is very verbose and error prone. Every data type needs to
be wrapped inside ItemConfig
; plus, you can technically add a non ItemConfig
type and pass in whatever you want. It would be much better if we can define the
types for the generic and the types get automatically wrapped in ItemConfig
:
const config: Config<[number, string]> = [
{
data: [1, 2, 3, 4, 5],
update(point) {
// typeof point === number
console.log(point);
},
},
{
data: ["a", "b", "c", "d"],
update(point) {
// typeof point === string
console.log(point);
},
},
];
After a lot of research, I have come across two concepts: The infer
keyword
and tuple desctructuring. I am going to give a quick overview of how they work
in my own words, then we can jump right into how these concepts allowed me to
achieve this.
The infer
keyword
The infer
keyword allows us to extract type from a complex type if a specific
condition is met. Example:
type ExtractDataTypeFromItemConfig<Type> = Type extends ItemConfig<
infer DataType
>
? DataType
: never;
type NumberType = ExtractDataTypeFromItemConfig<ItemConfig<number>>; // == number
type NeverType = ExtractDataTypeFromItemConfig<string[]>; // never
In the code above, we are doing a conditional check to see if Type
generic is
extended from ItemConfig
. However, we do not know the generic type argument of
ItemConfig
; so, the infer
keywords allows us to infer it and assign it to a
newly created type named DataType
.
Let's check two examples, one for each use-case.
type NumberType = ExtractDataTypeFromItemConfig<ItemConfig<number>>;
Since the passed type is extended from ItemConfig
(it is the same as
ItemConfig); so, the the generic argument can be inferred. In this example, the
utility will return number
.
type NeverType = ExtractDataTypeFromItemConfig<string[]>;
The passes type is not extended from ItemConfig
type; so, the utility will
return the "else" case, which is never
.
Spreading tuples
Tuples or arrays can be spread using the ...
syntax:
type A = ["a", "b", "c"];
type B = ["d", "e", "f"];
type Merged = [...A, ...B]; // ['a', 'b', 'c', 'd', 'e', 'f']
Putting it all together
By using infer
and tuple spreading, we can recursively wrap each element of a
tuple type with anything; so, let's wrap them with ItemConfig
:
type Config<T> = T extends [infer I, ...infer Rest]
? [ItemConfig<I>, ...Config<Rest>]
: [];
What are we doing here? Let's look at an example below and iterate it step by step:
type Res = Config<[string, number, boolean]>;
[string, number, boolean]
is a tuple; so,I = string, Rest = [number, boolean]
and the Config returns[ItemConfig<string>, ...Config<[number, boolean]>]
[number, boolean]
is a tuple; so,I = number, Rest = [boolean]
and Config returns[ItemConfig<number>, Config<[boolean]>]
[boolean]
is a tuple; so,I = boolean, Rest = []
and Config returns[ItemConfig<boolean>, Config<[]>]
[]
is not a tuple; so, Config returns the "else" condition, which is[]
- If we stich all the types together, we get the following final expression:
[ItemConfig<string>, ...[ItemConfig<number>, ...[ItemConfig<boolean>, ...[]]]]
- Evaluating the expression will give us
[ItemConfig<string>, ItemConfig<nu mber>, ItemConfig<boolean>]
Final Thoughts
This was a very interesting experiment. Understanding and utilizing infer
keyword in Typescript gave me a lot of insight on not just how type inference
works in Typescript but also on various applications for this concept.
The final API that I wanted to achieve was to use use variadic generic arguments instead of tuples in my type:
const data: Config<string, number, boolean>;
Unfortunately, this is not possible in Typescript 4.5. I am going to keep an eye on this since I believe that variadic generic arguments can open doors to a lot of interesting use-cases that are currently not possible.
Changelog
- Changed the word "destructuring" to "spreading"