Type-level arithmetic
TypeScript has no type-level numbers, so you encode N as a tuple of length N — concatenate to add, infer-off the front to subtract, and compare lengths to order — bounded and slow by construction.
You are typing a fixed-size matrix library: Vec<3> plus Vec<3> is fine, but Vec<3> plus Vec<4> must be a compile error, and dot(Vec<3>, Vec<3>) should return a number only when the lengths match. To express “3 + 4” or “is 3 less than 4?” the compiler needs arithmetic — but TypeScript has no type-level +. The trick the entire type-challenges arithmetic canon is built on: a number N is represented as a tuple of length N, and you do math by growing, shrinking, and measuring tuples. It works up to about a thousand — and it is gloriously slow.
The representation: a number is a tuple’s length
Ask yourself: if you can’t write 3 + 4 as a type expression, how do you enforce at compile time that two vectors have the same dimension? The whole trick fits in one insight. There is no + operator at the type level. The standard encoding is: the number N is any tuple with length equal to N. Build one by tail-recursively pushing unknown until the length matches:
type BuildTuple<N extends number, Acc extends unknown[] = []> =
Acc["length"] extends N ? Acc : BuildTuple<N, [unknown, ...Acc]>;
type Three = BuildTuple<3>;
// ^? type Three = [unknown, unknown, unknown]
type Len = Three["length"];
// ^? type Len = 3This is exactly the tail-recursive Build from lesson 1. The whole arithmetic library reduces to: convert numbers to tuples, manipulate the tuples, read ["length"] back out.
Add: concatenate the two tuples
Adding A + B is building both tuples, spreading them into one, and reading the combined length:
type Add<A extends number, B extends number> =
[...BuildTuple<A>, ...BuildTuple<B>]["length"] & number;
type Seven = Add<3, 4>;
// ^? type Seven = 7The & number narrows ["length"] (which is typed as number in general) back to the literal. Concatenation is the type-level +.
Subtract: infer the difference off the front
Subtracting A - B builds the tuple for A, then strips B elements from the front by matching a prefix of BuildTuple<B> and inferring the rest:
type Subtract<A extends number, B extends number> =
BuildTuple<A> extends [...BuildTuple<B>, ...infer Rest]
? Rest["length"]
: never; // A < B: no such prefix, underflow -> never
type Two = Subtract<5, 3>;
// ^? type Two = 2
type Under = Subtract<3, 5>;
// ^? type Under = never (negative numbers are unrepresentable)The pattern [...BuildTuple<B>, ...infer Rest] says “a tuple that starts with B elements, with Rest being whatever follows.” Rest["length"] is A - B. There is no representation for negatives, so underflow falls through to never — a deliberate signal, not a bug.
Compare: race the lengths
GreaterThan<A, B> walks both tuples down in lockstep; whichever empties first is smaller:
type GreaterThan<A extends number, B extends number> =
BuildTuple<B> extends [...BuildTuple<A>, ...infer _Rest]
? false // B is at least as long as A -> A <= B
: true; // B is NOT a prefix-superset of A -> A > B
type G1 = GreaterThan<5, 3>; // ^? type G1 = true
type G2 = GreaterThan<3, 5>; // ^? type G2 = false
type G3 = GreaterThan<4, 4>; // ^? type G3 = falseParsing numeric literals from strings
When you write a router type that extracts :42 from a path string, you need a numeric type back, not a string. ${number} in a template literal matches a numeric string, but the captured infer is still a string. To get a numeric type back, infer it then check extends number after a coercion through another template:
type ParseInt<S extends string> =
S extends `${infer N extends number}` ? N : never;
type P = ParseInt<"42">;
// ^? type P = 42 (the `infer N extends number` does the string->number type coercion)infer N extends number (TS 4.8+) both captures and coerces: it tries to interpret the matched substring as a numeric literal type. This is how router/path types pull :42-style segments into numbers.
▸Why this works
Why is this bounded and slow? Every number is materialized as an actual tuple of that length, and BuildTuple<N> runs N tail-recursive steps. Add<400, 400> builds two 400-element tuples and concatenates an 800-element one — and the compiler does this on every reference, every keystroke in your editor. The tuple-length ceiling sits near the recursion limit (~1000 with tail calls), so arithmetic above a few hundred either errors with “excessively deep” or makes type checking visibly lag. This is a correctness tool for small fixed sizes, not a general calculator.
Why does `Subtract<3, 5>` resolve to `never` rather than `-2`?
Order the evaluation of Add<2, 3> from number encoding to the final numeric type.
- 1 BuildTuple<2> -> [unknown, unknown]
- 2 BuildTuple<3> -> [unknown, unknown, unknown]
- 3 Spread both into one tuple -> [unknown, unknown, unknown, unknown, unknown]
- 4 Read ['length'] of the combined tuple -> 5
- 5 Narrow with & number to the literal type 5
- 01How are numbers represented at the type level, and how do you implement Add and Subtract on that representation?
- 02How do you compare two type-level numbers, and how do you parse a numeric literal out of a string?
- 03Why is tuple-length arithmetic both bounded and slow, and what is it actually good for?
Type-level arithmetic has one core idea: since TypeScript has no type-level numbers, represent N as a tuple of length N. BuildTuple grows the tuple tail-recursively; Add concatenates and reads ["length"]; Subtract infers the remainder after a B-length prefix (underflow to never); GreaterThan checks prefix-superset relationships; and `${infer N extends number}` parses numeric literals out of strings. The whole scheme is correct for small fixed sizes and useful for typed fixed-length APIs and array-arity validation — but it is bounded near the ~1000 recursion limit and materializes real tuples the compiler re-evaluates constantly. Now when you see Vec<3> and Vec<4> failing to add, you know the compiler is counting tuple elements — and when it slows down, you know why.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.