TypeScript syntax from hell
2022-07-04
Now that my firstborn has established a sleeping routine, I can resume blogging.
TypeScript has a powerful type system. For day-to-day usage it's actually a bit too powerful in my opinion.
In this post I'll explore an uncommon use case for an already uncommon part of the language, namely the infer
keyword.
General description
The documentation gives a brief introduction from which we learn that:
infer
is only valid within a conditional type expression- It lets you extract types used in other types like so:
type PromiseResult<T> = T extends Promise<infer A> ? A : never;
type Result = PromiseResult<Promise<{a: string;}>>; // Result = {a: string;}
This is useful in and of itself, but pretty benign.
Example
Consider this innocent-looking one-liner:
function pickFields(input: {[key: string]: any}, ...fields: string[]) {
return fields.map(item => input[item]);
}
Pretty straightforward - given an object and a list of keys return an array of fields picked from that object. Now how to describe its return type?
We could go the obvious route and do this:
function pickFields<T>(input: T, ...fields: (keyof T)[]): T[keyof T][] {
return fields.map(item => input[item]);
}
Which is fine, but doesn't tell us much about the result, aside from the fact that we get an array of unknown length that has some fields from the input type.
Can we do better? Yes we can!
Basic solution
Let's start off by describing the type in natural language.
The return type of
pickFields
is an array of the same length as thefields
argument with the exact type in each position explicitly stated.
From that we can deduce that the function's type signature is going to look more or less like this:
type PickedFields<Type, T extends (keyof Type)[]> = unknown;
function pickFields<T, F extends (keyof T)[]>(input: T, ...fields: F): PickedFields<T, F>;
function pickFields<T>(input: T, ...fields: (keyof T)[]): any {
return fields.map(item => input[item]);
}
Note the usage of function overload.
Now that we have the input types, let's examine the output type case by case:
Passing just the input
argument to pickFields
should yield an empty array, so:
type PickedFields<Type, T extends (keyof Type)[]> = T extends [] ? [] : unknown;
Passing a single fields
entry gives us an array of size 1:
type PickedFields<Type, T extends (keyof Type)[]> =
T extends [] ? [] :
T extends [(keyof Type)] ? [Type[T[0]]] : unknown;
We can extend this to any number of arguments:
type PickedFields<Type, T extends (keyof Type)[]> =
T extends [] ? [] :
T extends [(keyof Type)] ? [Type[T[0]]] :
T extends [(keyof Type), (keyof Type)] ? [Type[T[0]], Type[T[1]]] :
T extends [(keyof Type), (keyof Type), (keyof Type)] ? [Type[T[0]], Type[T[1]], Type[T[2]]] : unknown;
But that quickly becomes unwieldy and it doesn't solve the general case. Clearly we're in need of a loop of some kind, but we (fortunately?) don't have that in the type system.
Keeping it infernal
What we do have though are recursive conditional types, introduced in TS 4.1. Using them it's possible to create a recursive type solving the general case:
type PickedFields<Type, F extends (keyof Type)[]> =
F extends [] ? [] :
F extends [first: keyof Type, ...rest: infer R]
? [ Type[F[0]], ...(R extends (keyof Type)[] ? PickedFields<Type, R> : [])]
: never;
Line by line:
- Type definition along with input types.
- If
F
is an empty array - output an empty array type. - Otherwise check if
F
is an array, where the first element is a key ofType
and the rest is of some (inferred) type referred to asR
. - Output an array with the first element having the appropriate type and the rest defined as a
PickedFields
type, but usingR
, which is justF
, but without the first element. - This should never happen.
TypeScript doesn't narrow-down generic types in conditional expressions, so even though we know that first
has to be a keyof Type
, considering that F extends (keyof Type)[]
, we still have to explicitly state that.
Meanwhile R
might be (keyof Type)[]
or it could be an empty array, so infer
presents its type as unknown[]
.
Full example
type PickedFields<Type, F extends (keyof Type)[]> =
F extends [] ? [] :
F extends [first: keyof Type, ...rest: infer R]
? [ Type[F[0]], ...(R extends (keyof Type)[] ? PickedFields<Type, R> : [])]
: never;
function pickFields<T, F extends (keyof T)[]>(input: T, ...fields: F): PickedFields<T, F>;
function pickFields<T>(input: T, ...fields: (keyof T)[]): any {
return fields.map(item => input[item]);
}
interface ExampleInterface {
a: string;
b: number;
c: symbol;
}
const example: ExampleInterface = {
a: 'test',
b: 1,
c: Symbol()
};
// typeof exampleResult == [symbol, number, string]
const exampleResult = pickFields(example, 'c', 'b', 'a');