Type-level programming patterns

TypeScript · Expert · 18 min

Typed routers, ORMs, schema libraries and form libraries are built from these patterns; reading them requires knowing the idioms.

Conditional, mapped and template literal types together form a small functional language. Its idioms:

Recursion. A type alias can refer to itself. DeepReadonly<T> maps each property and recurses into objects; Flatten<T> unwraps nested arrays; Split<S, D> peels a string apart with infer.

Tuples as lists and numbers. Variadic tuple types let you spread and match: [...A, ...B] concatenates, T extends [infer H, ...infer R] destructures. A tuple's ["length"] is a numeric literal, so counting is done by growing a tuple — the standard trick for type-level arithmetic.

Accumulators and tail recursion. Since TS 4.5, conditional types that are tail-recursive — the recursive call is the whole result of a branch — are evaluated iteratively and may recurse about 1,000 levels deep. Non-tail-recursive types hit "Type instantiation is excessively deep and possibly infinite" much sooner. So write Reverse<T, Acc = []> that passes the result along in Acc rather than building [...Reverse<Rest>, Head].

Readable results. Intersections and nested mapped types display poorly. A helper like type Prettify<T> = { [K in keyof T]: T[K] } & {} flattens a type for hovers and error messages.

Testing types. Types are code; test them. A common pattern is an exact-equality check:

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

Libraries such as expect-type or Vitest's expectTypeOf package the same idea.

Restraint. Every clever type costs compile time and the next reader's patience. Use type-level code at library boundaries where it saves users real effort, keep internals simple, and prefer a runtime check plus a plain type when the type gymnastics outgrow their value.

Example

type DeepReadonly<T> = T extends (...args: never[]) => unknown ? T
  : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;

type Split<S extends string, D extends string, Acc extends string[] = []> =
  S extends `${infer Head}${D}${infer Tail}` ? Split<Tail, D, [...Acc, Head]> : [...Acc, S];

type Reverse<T extends unknown[], Acc extends unknown[] = []> =
  T extends [infer H, ...infer R] ? Reverse<R, [H, ...Acc]> : Acc;   // tail-recursive

type BuildTuple<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N ? Acc : BuildTuple<N, [...Acc, unknown]>;
type Add<A extends number, B extends number> = [...BuildTuple<A>, ...BuildTuple<B>]["length"];

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type Tests = [
  Expect<Equal<Split<"a.b.c", ".">, ["a", "b", "c"]>>,
  Expect<Equal<Reverse<[1, 2, 3]>, [3, 2, 1]>>,
  Expect<Equal<Add<3, 4>, 7>>,
  Expect<Equal<DeepReadonly<{ a: { b: number[] } }>["a"]["b"], readonly number[]>>,
];

const ok: Tests = [true, true, true, true];
console.log(ok.length);

Remember

  • Recursive conditional types plus infer let you parse and transform types
  • Tuples double as lists and numbers (via ["length"])
  • Write tail-recursive types with accumulators to recurse deeply
  • Test complex types with an exact Equal check

Watch out for

  • Building non-tail-recursive types that fail with "excessively deep" errors on realistic inputs.

Check yourself

  1. Why is Reverse<R, [H, ...Acc]> preferred over [...Reverse<R>, H]?
  2. How do type-level libraries usually represent the number 3 for arithmetic?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/2/types-from-types.html