TypeScript 4.6: Improved Recursion Depth Checks
TypeScript 4.6 RC dropped on February 11th. Most coverage will focus on the shiny stuff—control flow analysis for destructured discriminated unions, the new --generateTrace flag, ES2022 target. Those are fine. But I want to dig into something most developers won’t notice: improved recursion depth checks.
It’s the kind of change that sounds boring until you realize it’s cutting type-check times in half for some libraries.
The old heuristic #
When TypeScript checks if two types are compatible, it sometimes hits recursive structures. Think Tree<T> = { value: T; children: Tree<T>[] }. Checking compatibility means recursively checking children, which means checking Tree again, and so on.
The compiler needs to detect infinite recursion and bail out. The old heuristic was blunt: if the same generic type constructor appeared at 5 successive depths during a type relation check, the compiler assumed infinite recursion and stopped.
Too aggressive. Manually nested types like Foo<Foo<Foo<string>>> would hit the counter even though they’re finite. Each Foo instantiation incremented the counter, and at 5 levels the compiler gave up—even though there was nothing recursive about the actual type.
The new heuristic #
Anders Hejlsberg’s PR (#46599) changes the strategy. Instead of counting 5 instances of the same type constructor at increasing depth, the new heuristic requires only 3—but with a twist: the type IDs must be increasing.
Why type IDs? When the compiler creates a new type instantiation, it assigns a monotonically increasing ID. Recursive instantiations—where the type expands during checking—produce new types with increasing IDs at each depth. But manually nested types like Foo<Foo<Foo<string>>> are created at parse time; their IDs are assigned in source order, not during recursive expansion.
So the new heuristic can tell the difference. Same type constructor + increasing depth + increasing IDs = real recursion. Same constructor + increasing depth + stable IDs = just a nested type someone wrote.
// NOT recursive expansion---manually nested at parse time
type A = Foo<Foo<Foo<Foo<Foo<string>>>>>;
// Recursive expansion---compiler generates new types during checking
type Tree<T> = { value: T; left: Tree<T>; right: Tree<T> };Three checks instead of five means faster detection of real infinite recursion. The ID constraint means fewer false positives on finite nested types. Both at once.
Real-world impact #
The TypeScript team benchmarked against DefinitelyTyped and found measurable wins. Libraries with complex type structures saw roughly 50% reductions in type-check time. The blog post calls out redux-immutable, react-lazylog, and yup specifically.
These aren’t obscure packages. yup is everywhere in React. Faster type-checking for yup means faster IDE feedback and faster CI builds for a lot of teams.
Other things in the RC #
Destructured discriminated union control flow analysis. Before 4.6, destructuring a discriminated union lost type narrowing:
type Action =
| { type: 'increment'; amount: number }
| { type: 'decrement'; amount: number };
function handle(action: Action) {
const { type, amount } = action;
if (type === 'increment') {
// Before 4.6: amount is still number (no narrowing)
// After 4.6: amount is number (narrowed via discrimination)
}
}I use this pattern constantly. The lack of narrowing after destructuring has been a minor but persistent annoyance. Good fix.
ES2022 target. Following 4.5’s es2022 module target, 4.6 adds es2022 as an emit target. Class fields, top-level await, Error.cause, Object.hasOwn—all emitted natively without downleveling.
--generateTrace for performance debugging. This produces a trace file you can load into Chrome’s chrome://tracing viewer. Shows where the compiler spends time during type-checking. If you maintain a library with complex types, this will be invaluable for identifying expensive constructions.
I’m testing the RC in a medium-sized codebase—about 400 TypeScript files—and haven’t hit breaking changes so far. The recursion depth improvement is invisible in the best way: nothing breaks, nothing changes in your code, your builds just get faster. Those are the best kind of compiler changes.