Skip to content

TypeScript Performance at Scale

advanced13 min read

When TypeScript Gets Slow

Here's something that catches teams off guard: TypeScript compilation speed scales with type complexity, not just file count. A 100-file project with deeply nested conditional types can compile slower than a 10,000-file project with simple interfaces. Understanding what makes TypeScript slow lets you design types that scale.

Mental Model

Think of the TypeScript compiler as a calculator with a budget. Simple types (interfaces, basic generics) are cheap — like basic arithmetic. Complex types (deep conditional chains, recursive mapped types, large intersections) are expensive — like solving equations. The compiler has a time budget for each file. If your types exceed the budget, compilation slows to a crawl or hits the "excessively deep" error.

What Makes Types Slow

1. Large Union Types

// Slow — each operation on this union distributes across all members
type LargeUnion =
  | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"
  | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
  // ... hundreds more

// Template literals with large unions create cartesian products
type AllRoutes = `${HttpMethod} ${LargePathUnion}`;
// 5 methods × 200 paths = 1000 types generated

2. Deep Conditional Type Chains

// Each conditional instantiates new types
type DeepConditional<T> =
  T extends string ? "string" :
  T extends number ? "number" :
  T extends boolean ? "boolean" :
  T extends null ? "null" :
  T extends undefined ? "undefined" :
  T extends Function ? "function" :
  T extends Array<infer U> ? ["array", DeepConditional<U>] :
  T extends object ? { [K in keyof T]: DeepConditional<T[K]> } :
  "unknown";

// Applied to a large type, this recursively instantiates for every property

3. Complex Intersections

// Slow — intersections must be flattened for comparison
type SlowProps = BaseProps & LayoutProps & StyleProps & EventProps & AccessibilityProps;

// Faster — single interface with extends
interface FastProps extends BaseProps, LayoutProps, StyleProps, EventProps, AccessibilityProps {}

4. Deeply Recursive Types

// Each recursion level multiplies type instantiations
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// Applied to a 50-property object 5 levels deep:
// 50 × 50 × 50 × 50 × 50 = 312.5 million potential instantiations
Common Trap

The Type instantiation is excessively deep and possibly infinite error (TS2589) has a hard limit of about 50 recursive instantiation levels. But even before hitting this limit, compilation can be unacceptably slow. A type that recurses 30 levels deep with branching at each level can generate millions of type nodes.

Measuring Compilation Performance

Before you optimize anything, you need to know where the time is going. Don't guess — profile.

--generateTrace

TypeScript's built-in tracing reveals exactly where time is spent:

# Generate a trace
tsc --generateTrace ./trace-output

# This creates:
# trace-output/trace.json — Chrome DevTools trace format
# trace-output/types.json — type statistics

# Open trace.json in Chrome DevTools (Performance tab)
# or https://ui.perfetto.dev/

--extendedDiagnostics

tsc --extendedDiagnostics

# Output:
# Files:              1,234
# Lines of Library:   45,678
# Lines of Definitions: 12,345
# Lines of TypeScript:  67,890
# Check time:        4.52s    ← type checking time
# Emit time:         1.23s    ← file generation time
# Total time:        6.89s

--listFiles

# See every file TypeScript processes
tsc --listFiles | wc -l  # Total file count

# Find unexpectedly included files
tsc --listFiles | grep node_modules | head -20

Optimization Patterns

Now for the fixes. These are the patterns that consistently make the biggest difference.

1. Use Interface Over Type Intersections

// Slow — anonymous flattened type, recomputed each reference
type Props = A & B & C & D & E;

// Fast — named cached interface
interface Props extends A, B, C, D, E {}

2. Avoid Unnecessary Distribution

// Slow — distributes over large unions
type Wrap<T> = T extends unknown ? { value: T } : never;
type Result = Wrap<LargeUnion>; // Creates N wrapper types

// Faster — non-distributive when distribution isn't needed
type Wrap<T> = { value: T };
type Result = Wrap<LargeUnion>; // Single type { value: LargeUnion }

3. Cache Complex Types

// Slow — recomputed at every usage
function process(input: DeepPartial<ComplexType & MoreTypes>) {}
function validate(input: DeepPartial<ComplexType & MoreTypes>) {}
function store(input: DeepPartial<ComplexType & MoreTypes>) {}

// Fast — computed once, reused everywhere
type ProcessInput = DeepPartial<ComplexType & MoreTypes>;
function process(input: ProcessInput) {}
function validate(input: ProcessInput) {}
function store(input: ProcessInput) {}

4. Bounded Recursion

// Unbounded — can be slow on deep structures
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

// Bounded — stops after N levels
type DeepReadonly<T, Depth extends number[] = []> =
  Depth["length"] extends 5 ? T : {
    readonly [K in keyof T]: T[K] extends object
      ? DeepReadonly<T[K], [...Depth, 0]>
      : T[K];
  };

5. Simplify Generic Constraints

// Slow — complex conditional in constraint position
function process<T extends (T extends string ? { parse: (s: string) => T } : never)>(x: T) {}

// Fast — separate the constraint
type Parseable = { parse: (s: string) => unknown };
function process<T extends Parseable>(x: T) {}
How TypeScript's type cache works

TypeScript caches computed types by identity. Named types (interfaces, type aliases) are cached by their symbol. Anonymous types (intersections, inline types) are identified by structure.

// Cached — computed once for the type alias
type Cached = Partial<BigObject>;
function a(x: Cached) {} // Reuses cached type
function b(x: Cached) {} // Reuses cached type

// Not cached — recomputed each time
function a(x: Partial<BigObject>) {} // Computed here
function b(x: Partial<BigObject>) {} // Computed again

This is why extracting complex types into named aliases improves performance — the name gives TypeScript a cache key.

Production Scenario: Diagnosing a Slow Build

# Step 1: Measure baseline
time tsc --noEmit
# Real: 45.2s — too slow

# Step 2: Find where time is spent
tsc --extendedDiagnostics --noEmit
# Check time: 42.1s — it's type checking, not parsing or emitting

# Step 3: Generate trace
tsc --generateTrace ./trace --noEmit
# Open trace/trace.json in https://ui.perfetto.dev/

# Step 4: Identify expensive type instantiations
# Look for: structuredTypeRelatedTo, isTypeAssignableTo calls taking >100ms
# These reveal which type checks are expensive

# Step 5: Check types.json for explosion
cat trace/types.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
# Find types with high instantiation counts
"

# Common findings:
# - A DeepPartial applied to a 100-property type
# - A template literal with two 50-member unions (2500 types)
# - A recursive conditional type without depth bounds
# - Many type intersections that should be interface extends
Execution Trace
Trace
tsc --generateTrace ./trace
Generates trace.json and types.json
Open
Load trace.json in Perfetto UI
Visualizes compilation phases as flame charts
Find
Identify long bars in 'checkSourceFile'
Each bar is a file — longest bars are slowest files
Drill
Zoom into slow file's type checking
Find specific type operations taking >100ms
Fix
Simplify the identified types
Cache with aliases, use interfaces, bound recursion, reduce unions
What developers doWhat they should do
Using type intersections for object composition instead of interface extends
Intersections create anonymous types that must be recomputed. Interfaces are cached by name.
Use interface extends for object inheritance — it's cached and avoids flattening
Applying DeepPartial/DeepReadonly to very large types without bounds
Unbounded recursion on large types creates exponential type instantiations
Add depth limits to recursive types: stop after 3-5 levels
Using template literal types with large union cartesian products
Two 100-member unions in a template literal create 10,000 types
Keep unions under ~50 members in template literals, or use string with runtime validation
Not extracting repeated complex types into named aliases
Named aliases are cached. Inline complex types are recomputed at every reference.
Create a type alias: type MyType = Complex`<Thing>` and reuse it everywhere
Quiz
Why is interface extends faster than type intersection for object composition?
Quiz
What does tsc --generateTrace produce?
Quiz
How can you prevent a recursive type from causing 'excessively deep' errors?

Challenge: Optimize a Slow Type

// This type causes slow compilation. Identify why and optimize it.
// The optimized version should produce the same result but compile faster.

type SlowFormState<T> =
  Partial<T> &
  { errors: Partial<Record<keyof T, string>> } &
  { touched: Partial<Record<keyof T, boolean>> } &
  { dirty: Partial<Record<keyof T, boolean>> } &
  { isValid: boolean; isSubmitting: boolean };

// Used like:
// function useForm<T extends Record<string, unknown>>(
//   initial: T
// ): SlowFormState<T>

// Optimize this type:
// Problem: 5 intersections creating an anonymous flattened type,
// recomputed at every usage.

// Solution 1: Convert to a single interface
interface FormState<T> {
  values: Partial<T>;
  errors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
  dirty: Partial<Record<keyof T, boolean>>;
  isValid: boolean;
  isSubmitting: boolean;
}

// Solution 2: If you need the flat structure (values merged at top level),
// use a named type alias to cache the computation
type FormState<T> = {
  [K in keyof T]?: T[K];
} & {
  errors: { [K in keyof T]?: string };
  touched: { [K in keyof T]?: boolean };
  dirty: { [K in keyof T]?: boolean };
  isValid: boolean;
  isSubmitting: boolean;
};

// Then cache it for each concrete type:
type UserFormState = FormState<User>;

function useForm<T>(initial: T): FormState<T> { /* ... */ }

The key optimizations: (1) reduce 5 intersections to a single structured type, (2) use a named alias so the computed type is cached, (3) separate values from metadata properties instead of intersecting Partial<T> with everything.

Key Rules
  1. 1Use interface extends over type intersections for object composition — interfaces are cached, intersections are recomputed
  2. 2Extract complex types into named aliases — names give TypeScript a cache key for reuse
  3. 3Bound recursive types with depth counters — unbounded recursion on large types causes exponential instantiation
  4. 4Keep template literal union sizes small — cartesian products grow multiplicatively
  5. 5Use tsc --generateTrace and --extendedDiagnostics to measure before optimizing — profile first, then fix