- Book: The TypeScript Type System
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
The first frame of Doom took 12 days to render. None of the game's code ran as JavaScript. The TypeScript type checker evaluated it, at about 20 million type instantiations per second, until the frame came out the other end as a type.
That was February 2025. Dimitri Mitropoulos, founder of Michigan TypeScript, published a video showing Doom running on a WebAssembly virtual machine written entirely in TypeScript types. Simon Willison's write-up puts the scale at 177 TB of data, or about 3.5 trillion lines of type definitions. Socket's coverage adds that a typical TypeScript app might produce 200,000 to 500,000 type instantiations in total. Doom needed 20 million of them every second, for 12 days straight.
The stunt is absurd on purpose, but it runs on the same recursive conditional types, infer, and template literals you already use for route parsers and deep Partial helpers. You can build a small adder out of that machinery in a few lines, and the walls it hits are the ones Doom had to tear down.
What actually ran
Mitropoulos did not write Doom in types. He wrote a WebAssembly runtime in types, then fed it Doom compiled to WebAssembly. The runtime is public on GitHub and its README describes it as a WebAssembly runtime "implemented purely in TypeScript types": you give it a program, the type checker evaluates it, and the result is a type.
According to Socket, the runtime implements the 116 WebAssembly instructions Doom needs, and the type checker executed about 18 million instructions, one at a time, to draw the first frame. To do it, the VM simulates RAM, a call stack, memory pointers, and an execution context, along with a type-level garbage collector and an L1 cache. Numbers live as binary two's complement digits inside string literal types.
The same article reports that the project needed up to 100 GB of memory, and that the first estimate for one frame was 1.25 petabytes of types over three months, before optimizations cut it to 177 TB in 12 days.

The type checker as a machine: types go in, a type comes out.
Conditional types are a loop with an exit
Every type-level program is built from one primitive: a conditional type that refers to itself.
type Last<T extends unknown[]> =
T extends [...unknown[], infer L] ? L : never;
type TrimLeft<S extends string> =
S extends ` ${infer Rest}` ? TrimLeft<Rest> : S;
type A = Last<[1, 2, 3]>; // 3
type B = TrimLeft<" hi">; // "hi"
T extends X ? Yes : No is your if. infer is pattern matching that binds a variable. Referring to the alias inside its own branch is recursion. That is enough to compute anything a machine can, given unlimited time and memory, and tsc gives you neither.
TrimLeft is the example the TypeScript 4.5 release notes use, and it comes with a warning: before 4.5, a string with 50 leading spaces was enough to trigger "Type instantiation is excessively deep and possibly infinite."
Counting with tuple lengths
Types have no +. They do have tuples, and a tuple's length is a number literal type. So you count by building tuples.
type BuildTuple<
N extends number,
T extends unknown[] = [],
> = T["length"] extends N ? T : BuildTuple<N, [...T, unknown]>;
type Add<A extends number, B extends number> =
[...BuildTuple<A>, ...BuildTuple<B>]["length"];
type Five = Add<2, 3>; // 5
BuildTuple<3> keeps appending unknown until the tuple's length equals 3. Add spreads two of those tuples into one and reads the length back out. Subtraction is the same trick in reverse: match [...BuildTuple<B>, ...infer Rest] and take the length of Rest.
The Doom runtime uses this exact move at its core. Its add.ts turns each binary digit into a tuple of zero or one element, spreads the two digits plus the carry into a single tuple, and reads the length (0 to 3) to decide the output digit and the next carry. Tuple length is the adder.
The three walls: 100 deep, 1,000 long, 5 million total
Now try Add<1500, 1>. It should fail, and the way it fails tells you which limit you hit.
tsc enforces three separate limits. Each one exists so a bad type cannot hang your editor:
- Instantiation depth: 100. Nested instantiations, where evaluating one type requires evaluating another inside it, stop at a depth of 100. PR #45711 set this in TypeScript 4.5. The old value was 50; an earlier change had raised it to 500, and users hit stack overflows.
- Tail-recursive iterations: 1,000. The same PR made tsc evaluate a conditional type whose branch is another conditional type in a loop instead of on the call stack. That loop gives up after 1,000 iterations.
-
Instantiation count: 5 million. A running total of instantiations per checked expression or statement. Herrington Darkholme's walk through the checker source quotes the check as
instantiationDepth === 100 || instantiationCount >= 5000000, alongside thetailCount === 1000check for the loop.
BuildTuple is written in tail position: when it recurses, it returns the recursive result directly and does nothing else with it. That puts it in the 1,000-iteration loop instead of the 100-deep stack. Add<600, 700> should resolve to 1300, because each BuildTuple call stays under 1,000 iterations even though the final tuple is longer. Add<1500, 1> asks one BuildTuple for 1,500 iterations and runs into the cap.
Now break the tail position on purpose:
type Count<
N extends number,
T extends unknown[] = [],
> = T["length"] extends N
? []
: [unknown, ...Count<N, [...T, unknown]>];
Count<N> also produces a tuple of length N, but it wraps the recursive call in a spread, so each step has to wait for the next one to finish. That is the non-tail shape the 4.5 release notes say won't be optimized. It runs against the depth limit instead of the iteration limit, and it gives up far sooner than BuildTuple does. The fix is the one the release notes give for their GetChars example: move the work into an accumulator parameter so the recursive call is the whole branch.
I haven't run these snippets for this post. Paste them into the TypeScript Playground and hover the aliases to see where your version of the compiler draws the line.

Three different limits stop three different kinds of recursion.
Doom does not fit under any of these walls. Socket reports that Mitropoulos temporarily patched the compiler during development to remove the safeguards: the type-instantiation depth limit, the recursion limits, the stack depth limit when comparing types, and the maximum size of unions and tuples, which he set to infinity. You will not get that build from npm. The Go-based compiler behind TypeScript 7 keeps these limits too. When someone asked in September 2026 whether they would be revisited for the new compiler, a maintainer answered: "We have no plans to change these limits."
Arithmetic on string literals
Tuple counting does not scale to 32-bit numbers. You cannot build a tuple of four billion elements. So the Doom runtime stores each number as a string of bits and does arithmetic one character at a time, like the adder circuit in a CPU.
Here is a small version. Bits are little-endian, least significant first, so the carry flows in the same direction tsc reads the string:
type Sum<A extends string, B extends string, C extends string> =
`${A}${B}${C}` extends "000" ? ["0", "0"]
: `${A}${B}${C}` extends "001" | "010" | "100" ? ["1", "0"]
: `${A}${B}${C}` extends "011" | "101" | "110" ? ["0", "1"]
: ["1", "1"];
type AddBits<
A extends string,
B extends string,
C extends string = "0",
Out extends string = "",
> = A extends `${infer a}${infer RestA}`
? B extends `${infer b}${infer RestB}`
? Sum<a, b, C> extends [
infer D extends string,
infer K extends string,
]
? AddBits<RestA, RestB, K, `${Out}${D}`>
: never
: never
: C extends "1" ? `${Out}1` : Out;
// 3 + 1: "11" + "10" little-endian is "001", which is 4
type Four = AddBits<"11", "10">;
Sum is a one-bit full adder written as a lookup: three input bits, one output digit, one carry. AddBits peels the first character off each input with infer, adds them, appends the digit to Out, and recurses with the new carry. The recursive call sits at the end of a chain of conditional branches, so it is the shape PR #45711 evaluates in a loop. A 32-bit add takes about a hundred loop steps (three conditionals per bit), still well inside 1,000. The inputs must have the same length; the real runtime handles uneven inputs and pads to fixed widths.
The little-endian order matters. Template literal patterns match from the left, and ${infer a}${infer Rest} grabs the first character cheaply. Taking the last character means walking the whole string. Socket notes that because TypeScript only iterates strings from the left, Mitropoulos wrote all the binary algorithms in reverse. His add.ts reverses both inputs, adds, and reverses the result back, and a comment in the file calls that reversal a major recursion bottleneck.
Multiply, divide, and shift can follow the same pattern: a per-bit table plus a tail-recursive walk. Stack 116 of those instructions on a state machine that tracks memory and a program counter, and you have a CPU that runs in the checker.
What this means for the types you ship
You will never put Doom in your codebase. You will write a route parser, a typed SQL builder, a deep Paths<T> helper, or a schema-to-type mapper, and those run on the same machinery with the same three walls.
Keep recursion in tail position. If a recursive call is wrapped in a union, a spread, or an object, add an accumulator parameter and return the call bare. That moves your type from the 100-deep wall to the 1,000-iteration one.
Know which wall you hit. "Excessively deep and possibly infinite" is TS2589, and it can come from depth, from the iteration cap, or from the 5 million total. The fix differs: depth wants tail calls, the iteration cap wants shorter inputs or chunking, and the total wants fewer distributive unions in the hot path.
Next time TS2589 shows up in a helper you wrote, run tsc --extendedDiagnostics before you blame the IDE. It prints where the compiler spends its time and how many types it creates, which is your first clue to which of the three counters you blew.
If this was useful
Recursive conditional types, infer, template literal parsing, and the accumulator pattern that keeps you under the recursion limits are the core of The TypeScript Type System, book 2 in The TypeScript Library. If the AddBits walk made sense but you would not trust yourself to write the next one, that book takes you from generics to DSL-level types one step at a time.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)