Type-Level Programming and Its Limits
Recursion depth, instantiation limits and compile-time cost. What can be computed in types, what should be, and the error message a colleague will have to read.
Recursion depth, instantiation limits and compile-time cost. What can be computed in types, what should be, and the error message a colleague will have to read.
Somebody has written a type that parses SQL. Given
"SELECT id, name FROM photos", it produces
{ id: string; name: string }. It is four hundred lines, it is
genuinely impressive, and adding a WHERE clause takes two days
because nobody else can read it.
The type system is Turing complete, so almost anything is expressible. This lesson is about the boundary: what type-level programming buys, what it costs in ways that do not appear in a diff, and how to tell which side of the line you are on.
The tools from the earlier lessons compose into a small
functional language. Conditionals branch, infer destructures,
recursion iterates, and tuples give you a data structure:
type Length<T extends readonly unknown[]> = T["length"];
type Push<T extends readonly unknown[], V> = [...T, V];
type Reverse<T extends readonly unknown[]> = T extends [
infer Head,
...infer Rest,
]
? [...Reverse<Rest>, Head]
: [];Arithmetic is possible by using tuple length as a counter:
type BuildTuple<N extends number, R extends unknown[] = []> =
R["length"] extends N ? R : BuildTuple<N, [...R, unknown]>;
type Add<A extends number, B extends number> = [
...BuildTuple<A>,
...BuildTuple<B>,
]["length"];
type Six = Add<2, 4>; // 6That works and it is a warning as much as a demonstration. Counting to a thousand builds a thousand-element tuple, and the compiler is doing it on every keystroke in your editor.
Four, and each produces a specific error.
Recursion depth
Around fifty for ordinary recursion, and about a thousand for tail-recursive types the compiler can optimise.
Union size
Roughly 100,000 members. Long before the cap, the editor is unusable.
No mutation, no loops
Everything is recursion over immutable types, so an algorithm written imperatively has to be restructured.
Numbers are not numbers
There is no type-level arithmetic primitive. The tuple trick is what people use, and its cost is proportional to the value.
The first one is the one you will actually hit:
Writing the recursion in tail position — where the recursive call is the whole result rather than part of a larger expression — is what unlocks the higher limit.
Compile time and editor responsiveness. A deep type is evaluated repeatedly — on every hover, every completion, every keystroke in a file that uses it. This is the most common cause of a language server that lags.
Unreadable errors. A mismatch against a computed type produces the expansion, not the intent:
A colleague hitting that has to evaluate it by hand before they can even see what was expected.
A shrinking pool of maintainers. Everyone on the team can
read a function. Fewer can read a recursive conditional type
with four infers, and the ones who can are not always
available when it breaks.
Fragility across versions. Elaborate types depend on inference details that shift between releases. A TypeScript upgrade that changes nothing about your code can break a type that was working.
None of these are reasons never to. They are the price, and the mistake is paying it without noticing.
Bad — a type that computes what a value could carry.
Good — the parameters declared, and checked.
The first version is genuinely clever, and it earns its keep in a widely-used framework where thousands of callers benefit and one team maintains it. In application code it is a liability: the error when a route does not parse is the whole conditional chain, the recursion limits how long a path can be, and adding optional segments or wildcards means rewriting it.
The second states the parameters once, gets them checked in the handler, and any colleague can extend it. The duplication between the path and the array is real and small — and it is visible, which the parsing is not.
The test: could a colleague extend this without asking you? If the answer needs a paragraph, the type costs more than the bugs it prevents.
Three situations, and they share a shape.
A library boundary with many consumers. The cost is paid once by you; the benefit is multiplied by everyone using it. This is why framework types are elaborate and application types should not be.
Preventing an expensive class of bug. A type ensuring a query's parameters match its placeholders is worth real complexity, because the alternative failure is a runtime error in production.
A type derived from a single source of truth. Deriving handler types from a route table, or response types from a schema, keeps things in step that would otherwise drift — which is the utility-types argument at a larger scale.
What they have in common: the complexity is contained in one place and the benefit is spread across many. When the ratio is reversed — complexity everywhere, benefit in one call site — it is the wrong trade.
If you do write one, five things make it maintainable.
Name every intermediate step. One expression doing four things is unreadable and unbreakpointable; four named types compose the same way and can be inspected individually.
Write type tests. They are the only way to know the type still works:
Document with worked examples, because a reader's first move is to substitute a concrete type by hand. Save them the work.
Cap the recursion deliberately, so a pathological input produces your error rather than the compiler's:
Provide an escape hatch. Let a caller supply the type explicitly when inference fails, so a bug in your type is not a wall.
You now know what the type system can compute, where it stops, and — the part that matters more — how to judge whether a type is worth its cost. The ratio question is the one to keep: complexity contained and benefit spread is a good trade, and the reverse is not.
Next is Declaration Files and Ambient Types, which moves
from computing types to describing code that has none. Writing
.d.ts by hand, declaring modules and globals, and augmenting
types you do not own.
Before you move on, find the most elaborate type in a codebase you work on and try to explain it to someone in two minutes. If you cannot, that is the measurement this lesson is about — and it is more informative than any benchmark.
Type instantiation is excessively deep and possibly infinite.Type 'string' is not assignable to type
'Head<Split<Trim<Uppercase<T>>, ",">> extends `${infer K} ${string}`
? K : never'WHAT IS COMPUTABLE
conditionals branch, infer destructures, recursion iterates,
tuples are the data structure
T["length"], [...A, B], [infer H, ...infer R]
arithmetic via tuple length - proportional to the VALUE
HARD LIMITS
recursion ~50, or ~1000 if tail-recursive
"excessively deep and possibly infinite"
unions ~100,000 - the editor dies long before
no mutation, no loops - restructure as recursion
no numeric primitive
COSTS THAT DO NOT SHOW IN A DIFF
editor lag: the type is re-evaluated on every keystroke
error messages show the EXPANSION, not the intent
fewer people can maintain it
inference details shift between TypeScript versions
THE TEST
could a colleague extend this without asking you?
if the answer needs a paragraph, it costs more than it prevents
WHERE IT PAYS
a library boundary with many consumers
preventing an expensive class of bug
deriving from a single source of truth
the shape: complexity in ONE place, benefit across MANY
reversed, it is the wrong trade
IF YOU WRITE ONE
name every intermediate step
write type tests - expectTypeOf, and compile them
document with a worked example; readers substitute by hand
cap recursion so YOUR error appears, not the compiler's
provide an explicit-type escape hatch
MEASURING
tsc --noEmit --extendedDiagnostics
tsc --generateTrace ./tracetype ParseRoute<S extends string> =
S extends `${infer _}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ParseRoute<`/${Rest}`>
: S extends `${infer _}:${infer Param}`
? { [K in Param]: string }
: {};
function route<S extends string>(
path: S,
handler: (params: ParseRoute<S>) => Response,
): void { ... }function route<const P extends readonly string[]>(
path: string,
params: P,
handler: (params: Record<P[number], string>) => Response,
): void { ... }
route("/photos/:photoId", ["photoId"], ({ photoId }) => ...);type Trimmed<S extends string> = ...;
type Segments<S extends string> = Split<Trimmed<S>, "/">;
type Params<S extends string> = Extract<Segments<S>[number], `:${string}`>;import { expectTypeOf } from "vitest";
expectTypeOf<Params<"/photos/:id">>().toEqualTypeOf<":id">();type Split<S extends string, D extends string, Depth extends unknown[] = []>
= Depth["length"] extends 20 ? never : ...;