DEV Community

Cover image for How `tsc` Crashes With 14 Lines of Recursive Types (And What TS 6 Doesn't Fix)
Gabriel Anhaia
Gabriel Anhaia

Posted on

How `tsc` Crashes With 14 Lines of Recursive Types (And What TS 6 Doesn't Fix)


You open a tRPC router on a Monday. The router speaks to a Drizzle schema with about four hundred columns across thirty tables. The procedure inputs are validated with Zod. Everything was fine on Friday. Today, your editor is grey. The TypeScript server has been spinning for ninety seconds. When it finally answers, the message is the one every TypeScript developer eventually meets:

error TS2589: Type instantiation is excessively deep and possibly infinite.
Enter fullscreen mode Exit fullscreen mode

You did not write a recursive type. You imported three libraries that did, and the composition of their types pushed the checker past the wall.

The wall is real. It has a number. It is 1000. TypeScript 6 did not move it. No public roadmap entry signals a change for TypeScript 7, and the published tsc-go source preserves the same numeric limit. The number is load-bearing for the type system's soundness, and the cure for hitting it has nothing to do with raising it.

What the 1000 actually is

TypeScript's checker has two kinds of recursion when it instantiates a type.

Tail recursion is the friendly kind. A conditional type that resolves to a recursive call in the true or false branch, with nothing wrapping it, can be unrolled by the checker without growing the call stack. Tail-recursive conditional types get a depth budget of about 1000 instantiations before the checker bails out with TS2589.

Non-tail recursion is the unfriendly kind. A recursive type that wraps the recursive call inside a tuple, an object literal, or a mapped type cannot be unrolled. Each step adds a frame to the real instantiation stack. The budget collapses to somewhere between 50 and 100 before the checker throws, and on bad days the process runs out of memory or stack first and tsc exits with a hard crash rather than a typed error.

Issue microsoft/TypeScript#38198 is the canonical example of a RangeError: Maximum call stack size exceeded from the checker, where Anders Hejlsberg diagnosed it as "infinite recursion through inferTypeForHomomorphicMappedType which only has a very weak recursion guard in place". Issue microsoft/TypeScript#47419 is the matching out-of-memory case for recursive template literal types under tsc --watch. Both are closed. Neither closure removed the underlying ceiling. They patched specific guards so that pathological inputs return a typed error instead of crashing the process. The ceiling is a feature.

The 14-line minimal repro

Drop this into a fresh .ts file with strict: true and a recent TypeScript. It is fourteen lines of types and one line of usage. The usage line is what tips the checker over the edge.

type Build<N extends number, A extends unknown[] = []> =
  A["length"] extends N ? A : Build<N, [unknown, ...A]>;

type Path<T, K extends PropertyKey[] = []> =
  T extends object
    ? { [P in keyof T]:
        [...K, P] | Path<T[P], [...K, P]>
      }[keyof T]
    : K;

type Nested<D extends number> =
  D extends 0 ? string : { value: string; next: Nested<Build<D>["length"]> };

type Boom = Path<Nested<60>>;
const x: Boom = ["value"]; // TS2589 in most setups; full crash in some
Enter fullscreen mode Exit fullscreen mode

What happens when the checker walks this:

  1. Build<60> builds a 60-length tuple via tail recursion. That is fine. The checker handles it inside the 1000 budget.
  2. Nested<60> produces a 60-deep nested object type. Each level is a real object, not a tail call.
  3. Path<Nested<60>> walks every key at every level and unions every prefix tuple [...K, P]. This is non-tail recursion through a mapped type, and the unions multiply at each level.

The checker is no longer counting one budget. It is counting one per branch of the union, and every union branch starts a new mapped-type instantiation. By the time you read level 30, the work is exponential in the depth, and the checker is spending milliseconds per node. By level 60, you are well past the 1000-instantiation guard inside Path for at least one branch. You either get TS2589, or tsc exits with RangeError: Maximum call stack size exceeded, which is the same issue category documented in #38198. On lower-memory machines or older Node versions, the crash hits before the typed error does.

This is not a bug. The checker is doing what you asked. You asked for a type that enumerates every prefix of every path through a 60-deep recursive object, the path count blows up super-polynomially in the depth, and the type system politely refuses to keep going.

The reason real codebases hit this without writing 14 dramatic lines is that Drizzle's column inference, tRPC's procedure builder, and Zod's infer chain together produce types that look exactly like Path<Nested<60>> once the types compose. You did not write the deep recursion. You wrote db.select().from(table).where(eq(...)), and the inferred return type did the rest.

Workaround 1: tail-recursion accumulator

Most non-tail recursion can be rewritten as tail recursion if you accept an extra type parameter as an accumulator. The classic example is reversing a tuple.

// Non-tail: each call wraps the recursion in a tuple constructor.
type ReverseSlow<T extends unknown[]> =
  T extends [infer H, ...infer R] ? [...ReverseSlow<R>, H] : [];

// Tail-recursive: the recursion is the last thing in the conditional,
// the accumulator builds up as we go.
type Reverse<T extends unknown[], Acc extends unknown[] = []> =
  T extends [infer H, ...infer R] ? Reverse<R, [H, ...Acc]> : Acc;
Enter fullscreen mode Exit fullscreen mode

ReverseSlow blows up around 50 elements. Reverse handles close to the full 1000-instantiation budget, because the checker recognises the recursive call as the tail position and unrolls it. The shape is identical to how a Lisp compiler decides which defun becomes a goto.

Take a real schema-mapping type that turns a Drizzle column list into a Zod object. The same trick converts a keyof T extends ... ? { [K in keyof T]: ... } : never pattern into a key-by-key tail recursion that walks one key at a time and appends to an accumulator object.

Workaround 2: depth counter with bail-out

The accumulator pattern needs you to control the recursion. When you do not (the recursion lives inside a library you imported), you can sometimes intercept it by capping the depth from the outside.

type DepthOf = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

type DeepReadonly<T, Depth extends number = 10> =
  Depth extends 0
    ? T
    : T extends object
      ? { readonly [K in keyof T]: DeepReadonly<T[K], DepthOf[Depth]> }
      : T;
Enter fullscreen mode Exit fullscreen mode

DepthOf[Depth] decrements the depth tracker. When Depth hits zero, the recursion stops returning T instead of recursing. You trade type fidelity for compile-time survival: at depth 10, types beyond the tenth level of nesting are weakly typed. In a Drizzle schema where the deepest nested column relation is six joins deep, that bargain is invisible.

This is a depth counter, not a budget reset: it gives the recursion a definite end before it eats your budget on its own.

Workaround 3: distribution flatten with & {}

Long unions of object types are one of the things that pushes the checker over the wall. The cure that lives in production codebases is a one-character incantation: intersect the resulting type with {}.

type Prettify<T> = { [K in keyof T]: T[K] } & {};

type RawResult = SomeBigInferredUnion<Schema>;
type Result = Prettify<RawResult>;
Enter fullscreen mode Exit fullscreen mode

& {} forces the checker to materialise the type into a single concrete shape rather than keep it as a lazy union of mapped projections. The checker stops walking the same paths repeatedly, the IntelliSense tooltip starts showing the field list instead of SomeBigInferredUnion<...>, and the instantiation counter resets on the boundary.

This is the workaround that lands in production codebases for tRPC + Zod + Drizzle stacks. It does not change the meaning of your type. It changes how the checker stores it, and that is enough.

Workaround 4: infer to break the chain

The fourth move is the most surgical. When you have a long chain of conditional types that each call the next, you can short-circuit the chain by introducing an infer step that lets the checker resolve a sub-expression eagerly.

// Long chain — each conditional waits on the next.
type Chain<T> =
  T extends Promise<infer U>
    ? Chain<U>
    : T extends Array<infer V>
      ? Chain<V>
      : T extends { value: infer W }
        ? Chain<W>
        : T;

// Broken into named steps that the checker can resolve and cache.
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type ItemOf<T> = T extends Array<infer V> ? V : T;
type ValueOf<T> = T extends { value: infer W } ? W : T;

type Resolved<T> = ValueOf<ItemOf<Unwrap<T>>>;
Enter fullscreen mode Exit fullscreen mode

The first form looks elegant and works for shallow inputs. On deep inputs it stays in the recursion budget the whole way down. The second form uses three single-step conditional types that the checker resolves and caches independently. The cache is per type alias, so once Unwrap<Promise<X>> has been resolved, it does not pay the cost again for the next caller.

In a tRPC router where every procedure infers output: ZodType through the same chain, that caching can collapse tsc time by an order of magnitude on the routers we have seen.

What TypeScript 6 actually changed

TypeScript 6 shipped real changes. Less context-sensitivity on this-less functions. ES2025 support, including the Temporal API and new Map upsert methods. New compiler option defaults. The removal of ES5 as a target. A long deprecation list for breakage planned in 7.0.

Recursion depth is not on the list. The 1000-instantiation tail-recursion budget is unchanged. The non-tail recursion guards in inferTypeForHomomorphicMappedType and getConditionalType still live where Hejlsberg put them in 2020. The diagnostic message has not been reworded. If you upgrade a 5.7 codebase to 6.0 expecting the depth ceiling to lift, you will be in the same place on Tuesday.

The same answer applies to tsc-go, the Go-language port. The Go rewrite is often around ten times faster, and that means the wall arrives sooner in wall-clock time. The published source preserves the same numeric limit. The recursion budget is part of the checker's correctness contract: the checker has to refuse types it cannot decide in bounded time, otherwise type-checking stops being decidable. Faster checking does not change that. It just gets you to the no faster.

The same logic applies to TypeScript 7. The 7.0 roadmap as published focuses on flag cleanup, the deprecations queued in 6.0, and bringing the Go port to feature parity. No public roadmap entry signals a change to the recursion limit. There is signal that more standard-library types will get checker-side fast paths so user code does not need to walk them itself.

A drill for when you hit the wall

When the message lands in your editor, you have a sequence to run before you reach for the workarounds.

First, find the type that is recursing. The error is reported at the call site, but the recursion lives elsewhere. Hover the call. Comment it out and assign the inferred type to a named alias on its own line. The error usually moves with it, and now you can see the actual recursive shape.

Second, count the depth. Single digits point at non-tail recursion: workarounds 3 or 4. Hundreds means a tail-recursion problem and workaround 1 is the right move. Unbounded depth, the kind that depends on user input the type system cannot know, calls for workaround 2.

Third, check whether you wrote it. If the recursion lives inside Drizzle, tRPC, Zod, or any other library, you are probably one Prettify away from a fix. If it lives inside your own helper type, the accumulator pattern is where you start.

Fourth, measure. Add --extendedDiagnostics to your tsc invocation and look at the Instantiations line. A healthy mid-size project produces a few million. A project hitting TS2589 produces tens of millions, concentrated in two or three call sites. The compiler will tell you which ones if you ask.

You will hit the wall again. You will know what to do.


If this was useful

The 1000-depth wall is one stop on a longer tour of how TypeScript's type system actually executes. The TypeScript Type System — From Generics to DSL-Level Types is the book that walks the rest: how mapped types, conditional types, infer, template literals, and variadic tuples compose into the DSL-level types production codebases ship today, and where the checker's budget lives for each construct. If the four workarounds in this post unblocked your build, the book is the next layer down.

  • The TypeScript Type System — deep dive: generics, mapped/conditional types, infer, template literals, branded types. Amazon
  • TypeScript in Production — production layer: tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR. Amazon

The full path, including bridges from JVM and PHP, lives at the 5-book collection.

The TypeScript Library — the 5-book collection

Top comments (0)