EdgeCases Logo
Apr 2026
TypeScript
Expert
10 min read

TypeScript Recursive Type Performance

Deeply nested recursive types crash the compiler. Learn optimization strategies, circular reference handling, and inference depth limits.

typescript
recursive-types
performance
compiler
circular-references
inference

Recursive types in TypeScript are a double-edged sword. They enable powerful abstractions like JSON and DeepPartial, but without careful design they cause exponential type instantiation, compiler crashes, and editor slowdowns that make development unbearable.

The Problem: Type Instantiation Explosion

TypeScript evaluates types at compile time. When you have recursive types, the compiler instantiates them repeatedly:

// Naive DeepPartial - looks innocent
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? DeepPartial<T[P]>  // ← Recursive call
    : T[P];
};

// But applying it to a large object:
interface LargeState {
  users: User[];          // DeepPartial

The result: "Type instantiation is excessively deep and possibly infinite" or VS Code hanging for 30+ seconds.

Pattern 1: Interface Caching

TypeScript caches named types (interfaces) better than anonymous type aliases:

// ❌ Bad: Anonymous types, no caching
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? DeepPartial<T[P]>
    : T[P];
};

// ✅ Good: Break into named interfaces
interface UserPartial {
  id?: string;
  name?: string;
  email?: string;
  // Cached by compiler, reused across instantiations
}

interface SettingsPartial {
  theme?: string;
  language?: string;
  notifications?: boolean;
}

// Compiler can reuse these cached interfaces
type StatePartial = {
  users?: UserPartial[];
  posts?: PostPartial[];
  settings?: SettingsPartial;
};

For truly recursive types, use helper types with explicit caching points:

// ✅ Good: Break recursion at regular intervals
type Partial<T> = {
  [P in keyof T]?: T[P] extends object
    ? PartialObject<T[P]>
    : T[P];
};

// Break recursion at specific depth
type PartialObject<T> = T extends any[]
  ? Partial<T[number]>[]
  : T extends object
  ? { [P in keyof T]?: Partial<T[P]> }
  : T;

Pattern 2: Tail-Recursion Optimization

TypeScript 4.5+ optimizes tail-recursive conditional types:

// ❌ Stack-heavy: Accumulates on stack
type Reverse<T extends any[]> =
  T extends [infer Head, ...infer Tail]
    ? [...Reverse<Tail>, Head]
    : [];

// ✅ Tail-recursive: Passes accumulator
type ReverseTail<T extends any[], Acc extends any[] = []> =
  T extends [infer Head, ...infer Tail]
    ? ReverseTail<Tail, [Head, ...Acc]>
    : Acc;

Tail recursion allows TypeScript to reuse the same type instantiation frame:

// Tail recursion with memoization
type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue };

// Without memoization, this causes exponential instantiation
// With memoization (TypeScript 4.5+), it's optimized

Edge Case 1: Circular References

Circular type references crash the compiler:

// ❌ Bad: Direct circular reference
interface Node {
  value: number;
  children: Node[];  // ← Circular!
}

// ❌ Bad: Indirect circular reference
interface User {
  profile: Profile;
}

interface Profile {
  user: User;  // ← Circular!
  bio: string;
}

Solutions:

// ✅ Good 1: Break with type alias
type NodeReference = Node;  // Breaks circularity

interface Node {
  value: number;
  children: NodeReference[];
}

// ✅ Good 2: Use interfaces
interface Node {
  value: number;
  children: Node[];  // Interfaces handle this better
}

// ✅ Good 3: Use generic parameter
interface TreeNode<T = TreeNode<T>> {
  value: T;
  children: TreeNode<T>[];
}

// Breaks circularity with type parameter

Edge Case 2: Inference Depth Limits

TypeScript has hard limits on type inference depth:

// ❌ Bad: Exceeds inference depth
type DeepPick<T, K extends string> = K extends `${infer L}.${infer R}`
  ? DeepPick<T[L], R>
  : T[K];

type Data = {
  a: { b: { c: { d: { e: number } } } };
};

// 💥 Error: Type instantiation is excessively deep
type Deep = DeepPick<Data, 'a.b.c.d.e'>;

Limit recursion depth:

// ✅ Good: Depth-limited recursion
type DeepPick<T, K extends string, Depth extends number[] = []> =
  K extends `${infer L}.${infer R}`
    ? Depth['length'] extends 10  // Limit to 10 levels
      ? never
      : DeepPick<T[L], R, [...Depth, 0]>
    : T[K];

type SafeDeep = DeepPick<Data, 'a.b.c.d.e'>;  // ✅ Works

Edge Case 3: Memoization Failures

TypeScript doesn't always memoize as expected:

// ❌ Bad: Each instantiation recomputed
type GetKeys<T> = keyof T;

type Keys1 = GetKeys<{ a: 1, b: 2 }>;  // "a" | "b"
type Keys2 = GetKeys<{ a: 1, b: 2 }>;  // "a" | "b"

// TypeScript recomputes even for identical types!

// ✅ Good: Precompute and reuse
type CommonKeys = "a" | "b";

type SafeGetKeys<T extends Record<CommonKeys, any>> = CommonKeys;

// Now memoized across all uses

Pattern 3: Defer Resolution

Defer type resolution until access:

// ✅ Defer with conditional type
type Defer<T> = T extends infer U ? U : never;

// Use in complex recursive types
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? Defer<DeepPartial<T[P]>>
    : T[P];
};

// Defer prevents eager instantiation

Pattern 4: Use Built-in Utility Types

TypeScript's built-in utilities are optimized:

// ❌ Bad: Custom deep partial (slow)
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? DeepPartial<T[P]>
    : T[P];
};

// ✅ Good: Use Partial for shallow cases
type ShallowState = Partial<State>;

// ✅ Good: Combine with built-ins
type ShallowReadonly<T> = Readonly<Partial<T>>;

Pattern 5: Branding for Recursive Types

Use branded types to break recursion:

// Brand a type to break circular reference
type NodeId = string & { readonly __brand: 'NodeId' };

interface Node {
  id: NodeId;
  children: NodeId[];  // Breaks circularity
  value: number;
}

// Now you need to explicitly cast when needed
function createNode(value: number): Node {
  return {
    id: crypto.randomUUID() as NodeId,
    children: [],
    value,
  };
}

Performance Monitoring

Profile TypeScript compilation:

# Measure compilation time
time npx tsc --noEmit

# Output: 45.2s

# With performance tracing
npx tsc --noEmit --generateTrace trace.json

# Analyze with Chrome DevTools
chrome://tracing
# Load trace.json

Use TypeScript's diagnostic tools:

// tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo",
    "diagnostics": true,
    "explainFiles": true
  }
}

Real-World Example: JSON Type

// ❌ Bad: Causes slowdowns
type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue };

// Every JSONValue reference triggers full type check

// ✅ Good: Predefined object shapes
type JSONObject<T> = { [K in keyof T]: JSONValue };

type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | JSONObject<any>;

Key Takeaways

  • Interface caching: Named interfaces cache better than anonymous types
  • Tail recursion: TypeScript 4.5+ optimizes tail-recursive types
  • Circular references: Break with type aliases, interfaces, or generics
  • Inference limits: Add depth limits to recursive types
  • Memoization: Precompute common types to avoid recomputation
  • Defer resolution: Use conditional types to defer instantiation
  • Build-in utilities: Leverage optimized built-in utility types

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Expert
TypeScript Distributive Conditional Types: The Union Distribution Rule
8 min
TypeScript
Expert
TypeScript Performance: Recursive Types & Build Times
6 min
Performance
Deep
The Web Animation Performance Tier List
6 min

Advertisement