In this chapter, we explore how we can compute with types at compile time in TypeScript.
Is computing with types useful in practice?
We first have to learn the foundations and some of the examples may seem a bit abstract. But those foundations help with solving practical problems – some of which are listed in the conclusion.
If you are using libraries, you can often get by without computing with types. If, however, you are writing libraries, it tends to come in handy.
TypeScript code has two levels of computation:
Program level | Type level | |
---|---|---|
Programming language | JavaScript | TypeScript excluding JS |
Operands | values | concrete types |
Operations | functions | generic types |
Invoking an operation | calling a function | instantiating a generic type |
Computation happens | at runtime | at compile time |
This is an example of computing at the type level:
type Result = Uppercase<'hello'>;
type _ = Assert<Equal<
Result, 'HELLO'
>>;
Uppercase
is a generic type. Its argument, in angular brackets, is the string literal type 'hello'
. The result of instantiating the generic type is the string literal type 'HELLO'
.
The analogous computation at the program level looks like this:
const result = 'hello'.toUpperCase();
assert.equal(
result, 'HELLO'
);
In the next subsection, we examine the “values” we can use at the type level. Then we’ll define our own type-level “functions”.
At the type level we can compute with the following “values”.
These are the primitive types:
undefined
null
boolean
number
bigint
string
symbol
Even though two of them look like JavaScript values, we are operating at the type level:
undefined
is a type whose only value is undefined
.
null
is a type whose only value is null
.
These are examples of literal types:
type BooleanLiteralType = true;
type NumberLiteralType = 12.34;
type BigIntLiteralType = 1234n;
type StringLiteralType = 'abc';
We are still operating at the type level:
true
is a type whose only value is true
. It is a subset of boolean
.
12.34
is a type whose only value is 12.34
. It is a subset of number
.
These are examples of non-generic object types:
RegExp
Date
Uint8Array
We can also compose types to produce new types – e.g.:
type InstantiatedGenericType = Array<number>;
type TupleType = [boolean, bigint];
type ObjectLiteralType = {
prop1: string,
prop2: number,
};
When computing with types, unions of literal types are often used to represent sets of values – e.g.:
type Person = {
givenName: string,
familyName: string,
};
type _ = Assert<Equal<
keyof Person,
'givenName' | 'familyName'
>>;
The keyof
operator returns the keys of an object type. And it uses a union of string literal types to do so.
The following example is type-level code (that runs at compile time):
type Pair<T> = [T, T]; // (A)
type Result = Pair<'abc'>; // (B)
type _ = Assert<Equal<
Result, ['abc', 'abc']
>>;
Pair
that has one type parameter called T
.
Result
to be the instantiation of Pair
with the string literal type 'abc'
.
Pair
contains type variables (T
, twice). When we instantiate it, those variables are replaced with concrete types.
The following example is similar program-level code (that runs at runtime):
const pair = (x) => [x, x];
const result = pair('abc');
assert.deepEqual(
result, ['abc', 'abc']
);
I like Angelika Langer’s definitions:
For example:
type Pair<T> = [T, T];
Pair
is a generic type. Pair<3>
is a parameterized type – an instantiation of Pair
. We say that Pair<3>
constructs (“returns”) the type [3, 3]
.
A concrete type is a specific (potentially compound) type that can be used in a type annotation:
let v1: number;
let v2: Pair<3>;
We can make a type parameter optional by specifying a default value via an equals sign (=
):
type Pair<T='hello'> = [T, T];
type _ = Assert<Equal<
Pair,
['hello', 'hello']
>>;
If a type parameter definition is just the variable, it accepts any type but we can also constrain which types it accepts – via the keyword extends
:
type NumberPair<T extends number> = [T, T];
type P1 = NumberPair<123>; // OK
type P2 = NumberPair<number>; // OK
// @ts-expect-error: Type 'string' does not satisfy the constraint 'number'.
type P3 = NumberPair<'abc'>;
T extends C
means that:
T
must be assignable to C
.
T
must be a subset of C
.
extends
in a parameter definition of a generic type is similar to the colon (:
) in a parameter definition of a function:
type NumberPair<T extends number> = [T, T];
const numberPair = (x: number) => [x, x];
We an also combine extends
with a parameter default value:
type NumberPair<T extends number = 0> = [T, T];
type _ = Assert<Equal<
NumberPair,
[0, 0]
>>;
typeof
type operator: referring to the program level from the type level(Non-type) variables and type expressions exist at two different levels:
Therefore, we can’t directly mention a variable inside a type expression. However, the type-level typeof
operator enables us to refer to the type of a variable inside a type expression:
let programLevelVariable = 'abc';
// The right-hand side of `=` is a type expression
type TypeLevelType = Array<typeof programLevelVariable>;
type _ = Assert<Equal<
TypeLevelType,
Array<string>
>>;
typeof
vs. type-level typeof
JavaScript also has a typeof
operator – one that operates at the program level. For a given value, it returns the name of its type as a string:
assert.equal(
typeof 'abc',
'string'
);
assert.equal(
typeof 123,
'number'
);
The results of type-level typeof
are usually much more complex than the results of program-level typeof
:
const robin = {
givenName: 'Robin',
familyName: 'Doe',
};
type _ = Assert<Equal<
typeof robin,
{
givenName: string;
familyName: string;
}
>>;
typeof
The operand must be an an identifier which can optionally be followed by member accesses (dot operator or square brackets operator). Example:
const article = {
tags: ['dev', 'typescript'],
};
type _ = [
Assert<Equal<
typeof article,
{
tags: string[];
}
>>,
Assert<Equal<
typeof article.tags,
string[]
>>,
Assert<Equal<
typeof article.tags[0],
string
>>,
];
Any other operand produces a syntax error:
type _ = typeof 'abc';
// Error: Identifier expected.
keyof
type operatorThe type operator keyof
lists the property keys of an object type:
type Obj = {
prop1: 'a',
prop2: 'b',
};
type _ = Assert<Equal<
keyof Obj,
'prop1' | 'prop2'
>>;
The property keys of an empty object type are the empty set never
:
type _ = Assert<Equal<
keyof {},
never
>>;
JavaScript treats all number keys (whether quoted or not) as strings:
> Object.keys({0: 'a', 1: 'b'})
[ '0', '1' ]
> Object.keys({'0': 'a', '1': 'b'})
[ '0', '1' ]
Similarly, Array elements are properties whose keys are stringified numbers:
> Object.keys(['a', 'b'])
[ '0', '1' ]
For information on what Array elements are in JavaScript, see “Exploring JavaScript”.
In object literal types, unquoted number keys are number literal types and quoted number keys are string literal types:
type _ = Assert<Equal<
keyof {0: 'a', '1': 'b'},
0 | '1'
>>;
TypeScript also makes that distinction if types are derived from JavaScript values:
const obj = {0: 'a', '1': 'b'};
type _ = Assert<Equal<
keyof typeof obj,
0 | '1'
>>;
The indices of an Array type are numbers (note the Includes
in the first line):
type _ = Assert<Includes<
keyof Array<string>,
number // type for all indices
>>;
keyof
and index signaturesThe key of a number index signature is number
:
type NumberIndexSignature = {
[k: number]: unknown,
};
type _ = Assert<Equal<
keyof NumberIndexSignature,
number
>>;
The key of a string index signature is string | number
because in JavaScript, number keys are a subset of string keys (as explained the previous subsection):
type StringIndexSignature = {
[k: string]: unknown,
};
type _ = Assert<Equal<
keyof StringIndexSignature,
string | number
>>;
keyof
of an ArrayThe keys of an Array type include a variety of types (note the Includes
in the first line):
type _ = Assert<Includes<
keyof Array<string>,
number | 'length' | 'push' | 'join'
>>;
The keys consist of:
number
for Array indices
.length
Array
methods: 'push' | 'join' | ···
keyof
of a tupleSince tuples are mostly Arrays, their keys look similar (note the Includes
in the first line):
type _ = Assert<Includes<
keyof ['a', 'b'],
number | '0' | '1' | 'length' | 'push' | 'join'
>>;
As with Arrays, there are number
, 'length'
and the names of methods. Additionally, there is a stringified index for each element.
For more information on this topic, including how to extract tuple indices, see “The keys of tuple types” (§37.3).
keyof
of intersection types and union typesThis is how keyof
handles intersection types and union types:
type A = { a: number, shared: string };
type B = { b: number, shared: string };
type _1 = Assert<Equal<
keyof (A & B),
'a' | 'b' | 'shared'
>>;
type _2 = Assert<Equal<
keyof (A | B),
'shared'
>>;
This makes sense if we remember that:
A & B
has the properties of both type A
and type B
.
A | B
has either the properties of type A
or the properties of type B
. That is, only properties that both types have in common are always there.
T[K]
The indexed access operator T[K]
returns the types of all properties of T
whose keys are assignable to type K
. T[K]
is also called a lookup type.
These are examples of the operator being used:
type Obj = {
0: 'a',
1: 'b',
prop0: 'c',
prop1: 'd',
[Symbol.iterator]: 'e',
};
type _ = [
Assert<Equal<
Obj[0 | 1],
'a' | 'b'
>>,
// The stringified versions of number keys work the same
Assert<Equal<
Obj['0' | '1'],
'a' | 'b'
>>,
Assert<Equal<
Obj['prop0' | 'prop1'],
'c' | 'd'
>>,
Assert<Equal<
Obj[keyof Obj],
'a' | 'b' | 'c' | 'd' | 'e'
>>,
// - Symbol.iterator is a value (program level).
// - typeof Symbol.iterator is a type (type level).
Assert<Equal<
Obj[typeof Symbol.iterator],
'e'
>>,
];
T[K]
: K
must be a subset of the keys of T
The type in brackets must be assignable to the type of all property keys (as computed by keyof
). That’s why Obj[string]
and Obj[number]
are not allowed here:
type Obj = {prop: 'yes'};
type _ = [
// @ts-expect-error: Type 'Obj' has no matching index signature for type
// 'string'.
Obj[string],
// @ts-expect-error: Type 'Obj' has no matching index signature for type
// 'number'.
Obj[number],
];
However, we can use string
and number
as index types if the indexed type has an index signature (line A):
type Obj = {
[key: string]: RegExp, // (A)
};
type _ = [
Assert<Equal<
keyof Obj, // (B)
string | number
>>,
Assert<Equal<
Obj[string],
RegExp
>>,
Assert<Equal<
Obj[number],
RegExp
>>,
];
keyof Obj
(line B) includes the type number
because number keys are a subset of string keys in JavaScript (and therefore in TypeScript).
Tuple types also support indexed access:
type Tuple = ['a', 'b', 'c'];
type _ = [
Assert<Equal<
Tuple[0 | 1],
'a' | 'b'
>>,
Assert<Equal<
Tuple['0' | '1'],
'a' | 'b'
>>,
Assert<Equal<
Tuple[number],
'a' | 'b' | 'c'
>>,
];
We can use number
as an index because the keyof
of a tuple includes the type number
(more information).
ValueOf
TypeScript has a keyof
operator but no valueof
operator. However, we can implement that operator ourselves:
type ValueOf<T> = T[keyof T];
type Obj = { a: string, b: number };
type _ = Assert<Equal<
ValueOf<Obj>,
string | number
>>;
The following function retrieves the value of the property of obj
whose key is key
:
function get<O, K extends keyof O>(obj: O, key: K): O[K] {
return obj[key];
}
const obj = {
a: 1,
b: 2,
};
const result = get(obj, 'a');
assert.equal(result, 1);
assertType<number>(result);
It’s interesting that, in addition to correctly computing the type of result
, TypeScript also warns us if we get the key wrong:
// @ts-expect-error: Argument of type '"aaa"' is not assignable to
// parameter of type '"a" | "b"'.
get(obj, 'aaa');
Thanks to the indexed access operator, we can easily map from one kind of type to another:
type TypeofLookupTable = {
'undefined': undefined,
'boolean': boolean,
'number': number,
'bigint': bigint,
'string': string,
'symbol': symbol,
'object': null | object,
'function': Function,
};
type TypeofResult = keyof TypeofLookupTable;
type TypeofStringToType<S extends TypeofResult> = TypeofLookupTable[S];
type _ = [
Assert<Equal<
TypeofStringToType<'undefined'>,
undefined
>>,
Assert<Equal<
TypeofStringToType<'bigint'>,
bigint
>>,
];
Alas, object literal types only work as lookup tables if the key type is a subset of string
, number
or symbol
. For other types, we need to work with tuples.
lib.dom.d.ts
The built-in type definitions for the DOM (lib.dom.d.ts
) use indexed access and a lookup table GlobalEventHandlersEventMap
:
interface GlobalEventHandlersEventMap {
"abort": UIEvent;
"animationcancel": AnimationEvent;
"animationend": AnimationEvent;
"animationiteration": AnimationEvent;
"animationstart": AnimationEvent;
"auxclick": MouseEvent;
"beforeinput": InputEvent;
"beforetoggle": Event;
"blur": FocusEvent;
"cancel": Event;
"canplay": Event;
"canplaythrough": Event;
"change": Event;
"click": MouseEvent;
// ···
}
/** One of the interfaces extended by interface `Window` */
interface GlobalEventHandlers {
// ···
addEventListener<K extends keyof GlobalEventHandlersEventMap>(
type: K, // a string
listener: (
this: GlobalEventHandlers,
ev: GlobalEventHandlersEventMap[K]
) => any,
options?: boolean | AddEventListenerOptions
): void;
}
C ? T : F
)A conditional type has the following syntax:
«Sub» extends «Super» ? «TrueBranch» : «FalseBranch»
If Sub
is assignable to Super
, the result of the conditional type is TrueBranch
. Otherwise, it is FalseBranch
. This is an example:
type IsNumber<T> = T extends number ? true : false;
type _ = [
Assert<Equal<
IsNumber<123>, true
>>,
Assert<Equal<
IsNumber<number>, true
>>,
Assert<Equal<
IsNumber<'abc'>, false
>>,
];
For more information see “Conditional types (C ? T : F
)” (§34).
infer
The infer
keyword can only be used in the condition of a conditional type and extracts parts of compound types into type variables – e.g., the following generic type extracts what’s inside the angle brackets of Array<>
:
type ElemType<Arr> = Arr extends Array<infer Elem> ? Elem : never;
type _ = Assert<Equal<
ElemType<Array<string>>, string
>>;
infer
has a lot in common with destructuring in JavaScript.
For more information, see “Extracting parts of compound types via infer
” (§35).
Normal programming languages let us define local variables to help with managing various bits of data. Alas, the type level of TypeScript does not have this feature. If it had, it would look like this:
type Result = let Var = «Value» in «Body»;
However, we can emulate it via infer
:
type Result = «Value» extends infer Var ? «Body» : never;
We can also define multiple variables at the same time:
type Result = [«Value1», «Value2», «Value3»] extends
infer [Var1, Var2, Var3]
? «Body»
: never
;
This is an example where this technique is useful:
type WrapTriple<T> = Promise<T> extends infer W
? [W, W, W]
: never
;
type _ = Assert<Equal<
WrapTriple<number>,
[Promise<number>, Promise<number>, Promise<number>]
>>;
In the “body” of a generic type, we can also use a different technique – a helper parameter with a default value (W
in the following code):
type WrapTriple2<T, W=Promise<T>> = [W, W, W];
{[K in U]: X}
Roughly, a mapped type creates a new version of an input type T
(usually an object type or a tuple type) by looping over its keys:
{
[K in keyof T]: «PropValue»
}
«PropValue»
is a type expression that often uses K
in some way. This is an example:
type InputObj = {
str: string,
num: number,
};
type Arrayify<Obj> = {
[K in keyof Obj]: Array<Obj[K]>
};
type _ = Assert<Equal<
Arrayify<InputObj>,
{
str: Array<string>,
num: Array<number>,
}
>>;
Template literal types have the same syntax as JavaScript template literals. Two important use cases for them are:
First, concatenating string literal types (the template literal is in line A, delimited by backticks):
type MethodName = 'compute';
type AsyncMethodName = `async${Capitalize<MethodName>}`; // (A)
type _ = Assert<Equal<
AsyncMethodName, 'asyncCompute'
>>;
Second, extracting parts of string literal types:
type AsyncMethodName = 'asyncCompute';
type MethodName = Uncapitalize<
AsyncMethodName extends `async${infer MN}` ? MN : never // (A)
>;
type _ = Assert<Equal<
MethodName, 'compute'
>>;
In line A, we extract part of AsyncMethodName
into the type variable MN
, via the infer
operator. That operator works similarly destructuring in JavaScript. It must be used inside a conditional type (Cond ? True : False
).
Both concatenating and extracting string literal types are useful in many situations, e.g. they enable us to transform the names of object properties.
In this section, we explore how to compute with union types.
We can intersect union types via &
:
type Union1 = 'a' | 'b' | 0 | 1;
type Union2 = 'b' | 'c' | 1 | 2;
type Intersection = Union1 & Union2;
type _ = Assert<Equal<
Intersection,
1 | 'b'
>>;
And, as expected, we can also compute unions via |
:
type Union1 = 'a' | 'b';
type Union2 = 'b' | 'c';
type UnionResult = Union1 | Union2;
type _ = Assert<Equal<
UnionResult,
'a' | 'b' | 'c'
>>;
One interesting phenomenon with union types is that some operations are distributive over them:
The next example demonstrates that applying the template literal type in line A to a union produces a union of string literal types.
type Union = 'l' | 'f' | 'r';
type _ = Assert<Equal<
`${Union}ight`, // (A)
'light' | 'fight' | 'right'
>>;
T[K]
are distributiveThe next example applies an indexed access type to a union of object literal types:
type Union = { prop: 1 } | { prop: 2 } | { prop: 3 };
type _ = Assert<Equal<
Union['prop'],
1 | 2 | 3
>>;
Because they are distributive, conditional types are the most important tool for working with union types. In this section, we explore a few examples. For more information, see “Conditional types are distributive over union types” (§34.2).
type WrapStrings<T> = T extends string ? Promise<T> : T;
type _ = [
Assert<Equal<
WrapStrings<'abc'>, // normal instantiation
Promise<'abc'>
>>,
Assert<Equal<
WrapStrings<123>, // normal instantiation
123
>>,
Assert<Equal<
WrapStrings<'a' | 'b' | 0 | 1>, // distributed instantiation
Promise<'a'> | Promise<'b'> | 0 | 1
>>,
];
type KeepStrings<T> = T extends string ? T : never;
type _ = [
Assert<Equal<
KeepStrings<'abc'>, // normal instantiation
'abc'
>>,
Assert<Equal<
KeepStrings<123>, // normal instantiation
never
>>,
Assert<Equal<
KeepStrings<'a' | 'b' | 0 | 1>, // distributed instantiation
'a' | 'b'
>>,
];
How to compute with object types is explained elsewhere:
See “Computing with tuple types” (§37):
One downside of computed return types of functions is that TypeScript often thinks that the type of the returned value doesn’t match the computed type. This is an example:
type PrependDollarSign<Obj> = {
[Key in (keyof Obj & string) as `$${Key}`]: Obj[Key]
};
function prependDollarSign<
Obj extends object
>(obj: Obj): PrependDollarSign<Obj> { // (A)
// @ts-expect-error: Type '{ [k: string]: any; }' is not assignable to
// type 'PrependDollarSign<Obj>'.
return Object.fromEntries( // (B)
Object.entries(obj)
.map(
([key, value]) => ['$'+key, value]
)
);
}
Sadly, the value returned in line B is not assignable to the return type specified in line A. There are several ways of fixing this error – all of them involve a type assertion (as
). This is one solution – using as any
in line B:
function prependDollarSign<
Obj extends object
>(obj: Obj): PrependDollarSign<Obj> { // (A)
return Object.fromEntries(
Object.entries(obj)
.map(
([key, value]) => ['$'+key, value]
)
) as any; // (B)
}
const dollarObject = prependDollarSign({
prop: 123,
});
assert.deepEqual(
dollarObject,
{
$prop: 123,
}
);
assertType<
{
$prop: number,
}
>(dollarObject);
Options for getting the return type right:
as any
(our current solution)
as unknown as PrependDollarSign<Obj>
(an alternative to what we have done).
as unknown
as unknown as PrependDollarSign<Obj>
I prefer the solution that is used above because inferred return types prevent some ways of generating .d.ts
files: “isolatedDeclarations
: generating .d.ts
files more efficiently” (§8.8.5)
Computing with types is fascinating:
Computed types do make code more complex. My general recommendation is: Keep your types as simple as possible and do type-level computations only if it’s absolutely necessary. In some cases, it may be possible to use simpler types by restructuring code and/or data.
On the other hand, there are projects where writing the types took cleverness, but using them is fun. One small example is a prototype of a simple SQL API that I wrote.