open atlas
↑ Back to track
TypeScript type system, deep TS · 05 · 04

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.

TS Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

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 = 3

This 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 = 7

The & 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 = false

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

Quiz

Why does `Subtract<3, 5>` resolve to `never` rather than `-2`?

Order the steps

Order the evaluation of Add<2, 3> from number encoding to the final numeric type.

  1. 1 BuildTuple<2> -> [unknown, unknown]
  2. 2 BuildTuple<3> -> [unknown, unknown, unknown]
  3. 3 Spread both into one tuple -> [unknown, unknown, unknown, unknown, unknown]
  4. 4 Read ['length'] of the combined tuple -> 5
  5. 5 Narrow with & number to the literal type 5
Recall before you leave
  1. 01
    How are numbers represented at the type level, and how do you implement Add and Subtract on that representation?
  2. 02
    How do you compare two type-level numbers, and how do you parse a numeric literal out of a string?
  3. 03
    Why is tuple-length arithmetic both bounded and slow, and what is it actually good for?
Recap

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.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.