A normal program spends CPU when you run it. A type-level program spends it when you edit it. Every conditional you write, every union you distribute over, every recursive unfold gets evaluated by tsc on save, by the language server on keystroke, and by your editor while you hover. The cost is real. It lands on the human in the loop, and past a certain point the compiler refuses to continue. Type-level code has a runtime, and the runtime is the type checker.

That changes how you reason about a type. Correctness is necessary but not sufficient. A type that gives the right answer on a five-element tuple and freezes the editor on a fifty-element one is a bug. Knowing where the walls are, and which diagnostics reveal the wall you just hit, separates a type that ships from one that gets reverted.

The recursion wall

The hard limit you meet first is depth. Define a type in terms of itself and the compiler will unfold it, but not forever. Cross the line and you get:

Type instantiation is excessively deep and possibly infinite. ts(2589)

Where that line falls depends on whether the recursive call sits in tail position. A non-tail-recursive conditional type, the kind that wraps work around its own result, runs out around 45-50 levels deep:

type Repeat<T, N extends number, Acc extends T[] = []> =
  Acc['length'] extends N ? Acc : [T, ...Repeat<T, N, [T, ...Acc]>];
//                                       ^ result is wrapped after the call: NOT tail

type Small = Repeat<'x', 40>;  // fine
type Big = Repeat<'x', 60>;    // error ts(2589): excessively deep

The [T, ...Repeat<...>] spread does work after the recursive call returns, so the compiler keeps each pending frame alive. It caps the stack defensively rather than risk a genuinely infinite type.

TypeScript 4.5 changed the ceiling for one specific shape. When the recursive call is returned directly, with nothing wrapped around it, the compiler eliminates the tail call and evaluates the recursion iteratively instead of stacking frames. The accumulator pattern puts the call in that position: state rides along in an extra type parameter, so the answer is complete the moment the base case fires. (The accumulator mechanics are worked out in detail here.)

type RepeatTail<T, N extends number, Acc extends T[] = []> =
  Acc['length'] extends N ? Acc : RepeatTail<T, N, [T, ...Acc]>;
//                                ^ result returned bare: tail call, eliminated

type Long = RepeatTail<'x', 500>;  // fine

A tail-recursive conditional iterates roughly a thousand times before TypeScript stops it. Treat both numbers, the ~50 and the ~1,000, as the practical ceilings of current compilers, not contractual guarantees. They have shifted between versions, and the exact figure depends on what else the recursion instantiates on each pass. The reliable takeaway is the gap between them: tail position buys you more than an order of magnitude.

Union explosion

Depth is the wall you hit deliberately. Width is the one you hit by accident. Distributive conditional types and template-literal types fan out across every member of a union, and when two unions meet the result is a product, not a sum.

type Size = 'sm' | 'md' | 'lg';
type Color = 'red' | 'green' | 'blue';

type Variant = `${Size}-${Color}`;
// 'sm-red' | 'sm-green' | 'sm-blue' | 'md-red' | ... 9 members

Nine is harmless. The arithmetic is the problem. A template literal over two unions of size N produces N×N members, and each interpolation point multiplies again. Three unions of twenty members is eight thousand string literals materialized and held in memory. A distributive conditional has the same shape, because T extends U ? ... : ... runs once per constituent of T and the results union back together. (The distribution rules are spelled out separately.)

type Pair<A, B> = A extends A ? (B extends B ? [A, B] : never) : never;

type Tiny = Pair<1 | 2, 'a' | 'b'>;  // [1,'a'] | [1,'b'] | [2,'a'] | [2,'b']
// widen each input to a 50-member union and this is 2,500 tuple instantiations

The failure mode here is rarely a clean error. It is a language server that takes three seconds to autocomplete and a tsc run that crept from four seconds to forty. Large unions are the most common reason a type-heavy file becomes unpleasant to edit, and the cost stays invisible until you measure it.

How the compiler keeps it bearable

TypeScript is not naive about repeated work. Instantiating a generic type with a given set of arguments produces a result the compiler caches. Ask for the same instantiation again and it reuses the cached node instead of recomputing. Identity here is structural, so Box<string> computed in two different files resolves to the same cached instantiation.

This is why the cheap types are monomorphic and shallow. A type always instantiated with the same handful of argument shapes hits the cache constantly. A type instantiated with a fresh, distinct argument on every call, a growing accumulator tuple, a unique template literal, defeats the cache by construction, because no two instantiations share arguments. That is the real cost of deep recursion: not just the frames, but a thousand cache misses, each one a fresh tuple the compiler has never seen.

A few corollaries follow. Interfaces are cached by name and compared structurally without re-expanding, so a named interface is usually cheaper for the checker to carry than a deep intersection it flattens on every comparison. And narrowing a union before you map over it, rather than after, keeps the combinatorial product small at the point where it would otherwise explode.

Diagnostics and tooling

You do not have to guess where the time goes. tsc will tell you.

The first command to reach for reports instantiation counts and a phase-by-phase time breakdown:

tsc --noEmit --extendedDiagnostics
Instantiations:           412905
Check time:                 2.91s
Total time:                 4.07s

A high instantiation count next to a slow check time points straight at a type that is fanning out. When you need to see which type, generate a trace and open it in a flame-graph viewer:

tsc --noEmit --generateTrace traceDir

That writes trace.json and types.json into traceDir. Load trace.json in Perfetto or chrome://tracing, and the checkExpression and structuredTypeRelatedTo spans show you the exact type instantiation eating the wall-clock. Wide bars are the explosions. Deep stacks are the recursion.

One more flag, for when the error itself is the problem. By default the compiler truncates long types in diagnostics, which turns a union-explosion error into an unreadable ...:

tsc --noEmit --noErrorTruncation

That prints the full type, which is ugly but sometimes the only way to see that your nine-member union quietly became nine thousand.

Staying under the limits

The guidance reduces to a short checklist. Prefer tail recursion: an accumulator in the argument is the single highest-impact change, and it is what moves a type from ts(2589) to a clean answer. When recursion has no natural tail form, cap it with an explicit depth counter, so a bug degrades to an error instead of an editor hang. Avoid gratuitous large unions, and when a product is unavoidable, narrow before you distribute. Reach for a named interface over a deep intersection where the structure allows it, so the compiler compares by reference rather than re-expanding.

The Wordle engine is the archive’s stress test for all of this. Scoring five letters across six guesses is dozens of recursive steps, comfortably inside the range where a naive non-tail formulation would hit the wall. It stays under the limit for one reason: every pass returns its recursive call directly, so the compiler eliminates the tail call and iterates instead of stacking. Rewrite a single branch to post-process its result and the whole thing falls over at ts(2589). The performance and the recursion strategy are the same decision viewed from two angles.

Measure before you optimize a type the same way you would a function. The flame graph rarely blames what you expected.