Topic 16.4
Type-Level Programming
In one line
Types can compute. With conditional types, infer, tuples and template literal types you can write small programs that run inside the checker: reverse a tuple, split a string, even add numbers. Knowing how, and when to stop, is the skill.
Think of it like this
A pocket calculator does arithmetic on numbers. A spreadsheet does it on cells. Type-level programming is a calculator whose "numbers" are types: you give it a type, it follows rules, and it gives you back another type. Nothing runs when your app runs. The answer is worked out by the compiler while you type, and you read it in a hover or an error.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Type-level programming
- Writing types that compute other types, using conditional types, infer, generics and recursion.
- Tuple
- An array type with a fixed length and a known type for each position, like [number, string].
- Recursion
- A type that refers to itself. It is the loop of the type-level language.
- Tail position
- The recursive call is the entire result, with nothing wrapped around it. The compiler can handle this deeper.
- Accumulator
- An extra type parameter that carries the result built so far through each step.
- TS2589
- Type instantiation is excessively deep and possibly infinite: the compiler gave up on a type that nested too far.
- Pattern matching
- Checking that a type has a certain shape (with extends and infer) and grabbing parts of it.
- Type challenge
- A small puzzle: implement a type, such as Reverse or Add, using only the type system.
Step by step
01Take a tuple apart
A pattern like T extends [infer F, ...infer R] asks: "is T a tuple with at least one element? If yes, call the first one F and the rest R". If T is empty, the pattern doesn't match and the other branch of the conditional is used.
type First<T extends unknown[]> = T extends [infer F, ...unknown[]] ? F : never;
type Rest<T extends unknown[]> = T extends [unknown, ...infer R] ? R : [];
type A = First<[1, "b", true]>; // 1
type B = Rest<[1, "b", true]>; // ["b", true]02Loop by recursion: Reverse
To reverse [1, 2, 3]: reverse [2, 3] (that gives [3, 2]) and put 1 at the end. The recursion stops at the empty tuple, where the answer is [].
03Parse strings with a template pattern
${infer H},${infer T} matches a string with a comma and cuts it into the part before (H) and the part after (T). infer H takes the shortest piece up to the first comma. Recursion on T splits the rest.
04Count with tuple length
Numbers in types are values like 3; there is no arithmetic on them. Tuples bring arithmetic with them: the length of [unknown, unknown, unknown] is the type 3. Build the tuple, read the length.
05Know where the walls are
Recursion that stays in tail position goes deepest. Wrapping the result ([...Reverse<R>, H]) uses more compiler stack per level. When the compiler stops, it says TS2589; you shorten the input, rewrite with an accumulator, or give up the clever type.
Try it yourself
- 1
Reverse your own tuple
In the first example, change
Tupto[1, 2, 3, 4]. Three more lines turn red (l,randrev), because their values were written for the old tuple. Before you read the errors, predict the newLast<Tup>,Rest<Tup>andReverse<Tup>, then edit the values until only thewrongline is still an error. (The answers are4,[2, 3, 4]and[4, 3, 2, 1].) - 2
Break the route
In the second example, change the first
urlcall so it passes only{ id: "7" }. Read the error: which key is missing?Then change the string inside
type Route = Params<...>to"/users/:id/:section"and predictRoute(it becomes"id" | "section"). - 3
Subtract too much
In the counting example, change
Sub<7, 4>toSub<4, 7>. The smaller tuple can't start with the bigger one, so you getnever. Say why before you look:diffbecomesnever, and the old value3is now refused (Type '3' is not assignable to type 'never').
Code & diagrams
Each alias is a tiny function from types to types. The line with wrong shows the result of Reverse is the tuple [true, 'b', 1]: its two mismatched positions are reported separately.
Console
(nothing was printed)Expected console output
1 true [ 'b', true ] [ true, 'b', 1 ] [ 1, 2, 3 ] [ 1, 2, 3 ]Params turns a route string into the union of its parameter names, and url() uses that union as the required keys. The last call misspells id and is refused, naming the key set it expected.
Console
(nothing was printed)Expected console output
/users/7/posts/42
[ 'a', 'b', 'c' ] hi { a: '1', b: '2' }BuildTuple adds one element per step until the length equals N: it is the type-level for loop. Add joins two tuples and reads the length. Sub asks whether the bigger tuple starts with the smaller one and measures what is left. Fib keeps two accumulator tuples.
Console
(nothing was printed)Expected console output
3 5 3 55DeepReadonly recurses into objects and arrays. Both assignments below its definition are refused, one saying read-only property and one saying push does not exist on a readonly array. They still run, because types are erased.
Console
(nothing was printed)Expected console output
[ 1, 2, 3, 4 ] a 5 [ 1, 1 ]// limits.ts: run with: node node_modules/typescript-6/bin/tsc -p . and node node_modules/typescript-7/bin/tsc -p .
type BuildTuple<N extends number, T extends unknown[] = []> =
T["length"] extends N ? T : BuildTuple<N, [...T, unknown]>;
const ok: BuildTuple<999>["length"] = 999;
const tooDeep: BuildTuple<1001>["length"] = 1001;
// 6.0 and 7.0: limits.ts(4,16): error TS2589: Type instantiation is excessively deep and possibly infinite.
// A recursion that wraps the result goes much less deep. In a file that holds only these lines:
type Reverse<T extends unknown[]> = T extends [infer H, ...infer R] ? [...Reverse<R>, H] : [];
const r49: Reverse<BuildTuple<49>>["length"] = 49;
// 6.0 and 7.0: error TS2589 (the same message). BuildTuple<48> in its place compiles.
// Instantiations are cached, so the result can depend on what else is in the file.
// Template literal inference and emoji:
type Chars<S extends string> = S extends `${infer H}${infer T}` ? [H, ...Chars<T>] : [];
const a: Chars<"😀abc"> = ["😀", "a", "b", "c"];
// 7.0: no error (the emoji is kept whole as one element).
// 6.0: four TS2322 errors on that line (the first: Type '"😀"' is not assignable to type '"<half an emoji>"'),
// because 6.0 splits the emoji into its two UTF-16 halves; "é😀" has length 3 in 6.0, 2 in 7.0.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Misspell a parameter
In the string-parsing example, change { id: "7", postId: "42" } in the first url call to { id: "7", post: "42" }.
Myth vs fact
Myth
Type-level code runs when the program runs.
Fact
It runs in the compiler, while you type or build. It leaves no trace in the JavaScript, and costs compile time, not run time.
Myth
Anything can be computed with enough recursion.
Fact
The compiler caps recursion depth and the total work (TS2589). Long inputs fail even when the logic is right.
Myth
Clever types are always better than simple ones.
Fact
Every clever type costs reading time, error-message quality and compile speed. Use them where many callers benefit.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The compiler can evaluate a conditional type whose recursive call is the entire result as a loop instead of nesting, so a recursive type that carries an accumulator and returns it as the whole result can run about a thousand steps. This course shows it only by experiment:
BuildTuple<999>compiles andBuildTuple<1000>does not. (The TypeScript 4.5 release notes say the compiler "performs some tail-recursion elimination on conditional types" when a branch is simply another conditional type or the recursive call, and give no number; the limit seen here is an implementation detail.) - ▸
Results of type instantiation are cached per set of arguments, so the same type used twice costs once, and the order of statements in a file can change whether a deep one fails. When you fight
TS2589, test the type alone in a scratch file first. - ▸
TypeScript 7 splits template literal strings by Unicode code point, TypeScript 6 by UTF-16 unit: a type that parses emoji or other non-BMP characters gives different results (see the last block). Keep such types out of shared library code until your whole toolchain is on 7.
Remember this
- 1
The problem this solves: Sometimes one fact is stored twice, once as a value and once as a type: a route string and the names of its parameters, or an event name and its payload. If the type is computed from the single source, the two can never drift apart. That computing is what this topic teaches. The language has only a few pieces, which you met in Phase 9 (Recall from Topics 9.1, 9.3, 9.4 and 9.5), plus tuples from Topic 5.2. A conditional type (
A extends B ? X : Y) isif. **inferpulls a piece out of a type that matched a pattern. A generic type is a function (its type parameters are its arguments). Recursion is a type that mentions itself, and it is the only loop there is. A tuple is the list data structure, and template literal types** are string pattern matching. - 2
Tuple manipulation. A tuple is a fixed-length array type like
[1, "b", true]. With a spread andinferyou can take it apart:T extends [infer F, ...unknown[]]bindsFto the first element,[...unknown[], infer L]binds the last, and[unknown, ...infer R]binds everything but the first. Putting pieces back with[...A, ...B]givesConcat,Pushand, with recursion,Reverse: take the head, reverse the rest, then add the head at the end. - 3
String parsing. A template literal type can match a pattern and extract pieces:
S extends${infer H},${infer T}`splits at the first comma. Calling that again on the rest isSplit. The same pattern turns a route like"/users/:id/posts/:postId"into the union"id" | "postId"`, which is how routers type their parameters. You'll see it feed a real function in the second example: a missing or misspelled parameter is a compile error. - 4
Counting with tuples. The type system has no
+. But a tuple'slengthis a number type, so you can build a tuple of length N by adding one element at a time until its length isN, and then read the length of two joined tuples to add:[...BuildTuple<2>, ...BuildTuple<3>]["length"]is5. Subtraction matches the bigger tuple against[...Small, ...infer Rest]. Fibonacci follows from two accumulator tuples. This is slow and limited (below), but it shows that the type system is a real programming language. - 5
Limits are real. Recursion is capped. A type that calls itself in tail position (the recursive call is the whole result, as in
BuildTuple) can go about a thousand levels deep:BuildTuple<999>works, whileBuildTuple<1000>andBuildTuple<1001>fail withTS2589: Type instantiation is excessively deep and possibly infinite. A recursion that does work after the call (likeReverse, which wraps the recursive result in a new tuple) hits the limit much sooner:Reverse<BuildTuple<48>>works andReverse<BuildTuple<49>>fails. Rewritten in tail form with an accumulator (RevAcc<R, [H, ...A]>), the same reversal handled 900 elements in a test. TypeScript 6.0 and 7.0 show the same behaviour in the runs below; the exact numbers belong to the compiler and can change between releases. - 6
When to stop. Type-level code has no debugger, no stack trace, and bad error messages. A rule that works: write it only if it removes a whole class of bugs for many callers (route parameters, event names, SQL builders in a library), keep it small and commented with examples, and test it with
// ^?queries or type tests (Topic 15.3). If a teammate cannot read it in a minute, a plain runtime check with a simple type is usually better. Topic 10.6 covers this trade-off.
Explain it without notes
What are the building blocks of type-level programming in TypeScript?
How would you reverse a tuple type, and why does it stop?
How can you add two numbers in the type system?
Why do recursive types sometimes fail with TS2589, and what can you do?
When is type-level programming worth it, and when is it not?
Practice
Write Last<T> (without peeking) and check Last<[1, 2, 3]> is 3 and Last<[]> is never.
Write Join<T extends string[], D extends string> so Join<["a", "b", "c"], "-"> is "a-b-c".
Trade-offs
- ↔
Type-level programming gives compile-time guarantees from a single source of truth, at the cost of compile time, harder error messages and a steeper learning curve for everyone who reads it.
- ↔
Counting and arithmetic tricks are best treated as demonstrations. For real work, prefer a plain
numbertype and a runtime check.
Done when you can
I can take a tuple apart and put it back together with spreads and
infer.I can write
Splitand a route parameter type with template literal patterns.I can explain how tuple length gives arithmetic and where it breaks down.
I can recognise TS2589 and name three ways to deal with it.
I can decide when a clever type is worth its cost.