HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

33 Overview: computing with types

In this chapter, we explore how we can compute with types at compile time in TypeScript.

Icon “question”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.

33.1 Computation in TypeScript: program level vs. type level

TypeScript code has two levels of computation:

Program levelType level
Programming languageJavaScriptTypeScript excluding JS
Operandsvaluesconcrete types
Operationsfunctionsgeneric types
Invoking an operationcalling a functioninstantiating a generic type
Computation happensat runtimeat 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”.

33.2 “Values” we can compute with at the type level

At the type level we can compute with the following “values”.

33.2.1 Primitive types

These are the primitive types:

Even though two of them look like JavaScript values, we are operating at the type level:

33.2.2 Literal types

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:

33.2.3 Non-generic object types

These are examples of non-generic object types:

33.2.4 Compound types

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,
};

33.2.5 Unions of literal types as sets of values

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.

33.3 Generic types are type-level functions

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']
>>;

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']
);

33.3.1 Terminology: generic type, parameterized type, concrete type

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>;

33.3.2 Optional type parameters

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']
>>;

33.3.3 Constraining type parameters

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:

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]
>>;

33.4 The 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>
>>;

33.4.1 Program-level 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;
  }
>>;

33.4.2 Syntax of 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.

33.5 The keyof type operator

The 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
>>;

33.5.1 Number keys: JavaScript vs. TypeScript

33.5.1.1 Number keys in JavaScript

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”.

33.5.1.2 Number keys in TypeScript

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
>>;

33.5.2 keyof and index signatures

The 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
>>;

33.5.3 keyof of an Array

The 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:

33.5.4 keyof of a tuple

Since 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).

33.5.5 keyof of intersection types and union types

This 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:

33.6 Indexed access types 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'
  >>,
];

33.6.1 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).

33.6.2 Indexed access of a tuple

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).

33.6.3 Example: implementing 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
>>;

33.6.4 Example: getting a property value

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');

33.6.5 Example: lookup table and indexed access

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.

33.6.6 Indexed access and lookup table in 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;
}

33.7 Conditional types (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).

33.7.1 Extracting parts of compound types via 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).

33.8 Defining local type variables

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
;

33.8.1 Example

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];

33.9 Mapped types {[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>,
  }
>>;

33.10 Template literal types: processing strings

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.

33.11 Computing with union types

In this section, we explore how to compute with union types.

33.11.1 Intersection and union

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'
>>;

33.11.2 Distributivity over union types

One interesting phenomenon with union types is that some operations are distributive over them:

33.11.3 Template literal types are distributive

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'
>>;

33.11.4 Indexed access types T[K] are distributive

The 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
>>;

33.11.5 Conditional types are distributive

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).

33.11.5.1 Mapping a union type
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
  >>,
];
33.11.5.2 Filtering a union type
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'
  >>,
];

33.12 Computing with object types

How to compute with object types is explained elsewhere:

33.13 Computing with tuple types

See “Computing with tuple types” (§37):

33.14 Computed return types of functions often don’t match returned values

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:

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)

33.15 Conclusion

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.