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
inferlet 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
Equalcheck
Watch out for
- Building non-tail-recursive types that fail with "excessively deep" errors on realistic inputs.