Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~26 min· topic 4 of 6

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.

tuples.tswhole filets
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 [].

Loop by recursion: Reversediagram
Rendering diagram…

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

    Reverse your own tuple

    In the first example, change Tup to [1, 2, 3, 4]. Three more lines turn red (l, r and rev), because their values were written for the old tuple. Before you read the errors, predict the new Last<Tup>, Rest<Tup> and Reverse<Tup>, then edit the values until only the wrong line is still an error. (The answers are 4, [2, 3, 4] and [4, 3, 2, 1].)

  2. 2

    Break the route

    In the second example, change the first url call 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 predict Route (it becomes "id" | "section").

  3. 3

    Subtract too much

    In the counting example, change Sub<7, 4> to Sub<4, 7>. The smaller tuple can't start with the bigger one, so you get never. Say why before you look: diff becomes never, and the old value 3 is now refused (Type '3' is not assignable to type 'never').

Code & diagrams

Tuple manipulation: First, Last, Rest, Reverse, Concat, Push

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 ]
String parsing: Split, Trim, route parameters, query strings

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' }
Counting with tuples: BuildTuple, Add, Sub, Fibonacci

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 55
More challenges: DeepReadonly, Flatten, TupleToUnion, string length

DeepReadonly 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 ]
Recursion limits and Unicode (non-live, tsc 6.0 and 7.0)ts
// 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" }.

terminal
$ Types panel
── what you'll see ──
index.ts(23,56): error TS2353: Object literal may only specify known properties, and 'post' does not exist in type 'Record<"id" | "postId", string>'.
index.ts(24,21): error TS2353: Object literal may only specify known properties, and 'name' does not exist in type 'Record<"id", string>'.

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 and BuildTuple<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. 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) is if. **infer pulls 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. 2

    Tuple manipulation. A tuple is a fixed-length array type like [1, "b", true]. With a spread and infer you can take it apart: T extends [infer F, ...unknown[]] binds F to the first element, [...unknown[], infer L] binds the last, and [unknown, ...infer R] binds everything but the first. Putting pieces back with [...A, ...B] gives Concat, Push and, with recursion, Reverse: take the head, reverse the rest, then add the head at the end.

  3. 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 is Split. 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. 4

    Counting with tuples. The type system has no +. But a tuple's length is a number type, so you can build a tuple of length N by adding one element at a time until its length is N, and then read the length of two joined tuples to add: [...BuildTuple<2>, ...BuildTuple<3>]["length"] is 5. 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. 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, while BuildTuple<1000> and BuildTuple<1001> fail with TS2589: Type instantiation is excessively deep and possibly infinite. A recursion that does work after the call (like Reverse, which wraps the recursive result in a new tuple) hits the limit much sooner: Reverse<BuildTuple<48>> works and Reverse<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. 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

01

What are the building blocks of type-level programming in TypeScript?

02

How would you reverse a tuple type, and why does it stop?

03

How can you add two numbers in the type system?

04

Why do recursive types sometimes fail with TS2589, and what can you do?

05

When is type-level programming worth it, and when is it not?

Practice

01

Write Last<T> (without peeking) and check Last<[1, 2, 3]> is 3 and Last<[]> is never.

02

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 number type 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 Split and 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.