EdgeCases Logo
Feb 2026
TypeScript
Expert
8 min read

TypeScript Template Literal Types: When Clever Types Explode

Template literal types compose beautifully—until they explode. Understand instantiation depth, Cartesian products, and recursive limits to avoid TS2589.

typescript
template-literals
performance
type-system
recursion
edge-case

Template literal types let you build powerful string manipulation at the type level. But push too hard and TypeScript crashes with "Type instantiation is excessively deep and possibly infinite" (TS2589). Understanding the limits helps you write types that don't explode your IDE.

The Power and the Problem

Template literal types compose beautifully:

type EventName = 'click' | 'focus' | 'blur';
type Handler = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts';
type Route = `${HTTPMethod} ${Endpoint}`;
// 'GET /users' | 'POST /users' | 'GET /posts' | ...

The problem: types grow combinatorially. HTTPMethod (4) × Endpoint (2) = 8 types. Add more variants and you hit TypeScript's limits fast.

The Hard Limits

TypeScript has several internal limits that template literals can hit:

  • Type instantiation depth: ~50 — recursive types can only nest so deep
  • Conditional type recursion: ~1000 — but practically limited by depth
  • Union member count: ~100,000 — large unions slow everything down
  • String length in types: browser/memory dependent
// TS2589: Type instantiation is excessively deep and possibly infinite
type Repeat<S extends string, N extends number> =
  N extends 0 ? '' : `${S}${Repeat<S, Decrement<N>>}`;

type TooDeep = Repeat<'a', 100>;  // 💥 Crashes

Edge Case 1: Cartesian Product Explosion

Unions in template positions multiply:

type A = 'a1' | 'a2' | 'a3';  // 3 members
type B = 'b1' | 'b2' | 'b3';  // 3 members  
type C = 'c1' | 'c2' | 'c3';  // 3 members

type Combined = `${A}-${B}-${C}`;
// 3 × 3 × 3 = 27 members

// With larger unions:
type Alphabet = 'a' | 'b' | 'c' | ... | 'z';  // 26
type ThreeLetters = `${Alphabet}${Alphabet}${Alphabet}`;
// 26 × 26 × 26 = 17,576 members 💥

TypeScript generates every combination. Beyond a few thousand, your IDE grinds to a halt.

Edge Case 2: Recursive String Parsing

Parsing strings recursively is a common pattern that hits depth limits:

// Split string by delimiter
type Split<S extends string, D extends string> =
  S extends `${infer Head}${D}${infer Tail}`
    ? [Head, ...Split<Tail, D>]
    : [S];

type Path = Split<'a/b/c/d/e', '/'>;  // Works
type DeepPath = Split<'a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p', '/'>;
// May hit recursion limits depending on TS version

Each recursion level adds to the instantiation depth. Long strings with many delimiters exceed the limit.

Workaround: Tail-Call Optimization Pattern

// Accumulator pattern reduces depth
type Split<
  S extends string,
  D extends string,
  Acc extends string[] = []
> = S extends `${infer Head}${D}${infer Tail}`
  ? Split<Tail, D, [...Acc, Head]>
  : [...Acc, S];

// The accumulator keeps intermediate results
// instead of building them up through the call stack

Edge Case 3: Distributive Conditional Types

Conditional types distribute over unions, multiplying work:

type ExtractPrefix<T extends string> = 
  T extends `${infer Prefix}_${string}` ? Prefix : never;

type Keys = 'user_id' | 'user_name' | 'post_id' | 'post_title' | ...;
// Each union member evaluates the conditional separately

// For 100 members, TypeScript evaluates 100 conditionals
// Nested conditionals: 100 × 100 = 10,000 evaluations

The fix: sometimes you can avoid distribution with mapped types:

// Instead of distributing, use a mapped type
type ExtractPrefixes<T extends string> = {
  [K in T]: K extends `${infer P}_${string}` ? P : never
}[T];

Edge Case 4: Infer in Template Positions

Multiple infer in template literals creates ambiguity that TypeScript must resolve through backtracking:

// Ambiguous: where does Head end and Tail begin?
type Parse<S extends string> = 
  S extends `${infer Head}${infer Tail}` 
    ? [Head, Tail] 
    : never;

type Result = Parse<'hello'>;
// Could be: ['', 'hello'] or ['h', 'ello'] or ['he', 'llo'] ...
// TypeScript picks ['h', 'ello'] (greedy single char for first infer)

This greedy behavior is fast, but complex patterns with multiple infers can cause exponential backtracking:

// Expensive: multiple ambiguous infers
type BadParse<S extends string> =
  S extends `${infer A}${infer B}${infer C}${infer D}`
    ? [A, B, C, D]
    : never;

// Better: Use delimiters or fixed patterns
type GoodParse<S extends string> =
  S extends `${infer A}-${infer B}-${infer C}-${infer D}`
    ? [A, B, C, D]
    : never;

Edge Case 5: The Cache Helps (Until It Doesn't)

TypeScript caches type instantiations. Identical type applications reuse cached results:

type Expensive<T extends string> = /* complex computation */;

// First use: computed
type A = Expensive<'hello'>;

// Second use: cached!
type B = Expensive<'hello'>;

// Different input: computed again
type C = Expensive<'world'>;

But the cache has limits. Unique inputs bypass caching, and memory-heavy types can exhaust the cache:

// Each unique T creates a new cache entry
type Generate<T extends string> = `prefix_${T}_suffix`;

// 1000 unique strings = 1000 cache entries
// Eventually, older entries are evicted

Practical Limits by Use Case

// ✅ Safe: Small unions, shallow composition
type Color = 'red' | 'blue' | 'green';
type Size = 'sm' | 'md' | 'lg';
type ClassName = `${Color}-${Size}`;  // 9 members

// ⚠️ Caution: Medium unions, watch the product
type Status = /* 10 options */;
type Action = /* 5 options */;
type Event = `${Status}_${Action}`;  // 50 members - OK but monitor

// ❌ Danger: Large unions or deep recursion
type AllRoutes = /* 100+ routes */;
type AllMethods = /* 4 methods */;
type AllEndpoints = `${AllMethods} ${AllRoutes}`;  // 400+ - getting risky

// 💥 Crash: Unbounded recursion or huge unions
type JSONPath = /* arbitrary depth parsing */;
type AllStrings = /* derived from large object keys */;

Escape Hatches

1. Use Branded Types Instead

// Instead of exhaustive template literal unions
type Route = `/users/${string}` | `/posts/${string}` | ...;

// Use a branded string type
type Route = string & { __brand: 'Route' };

function createRoute(path: string): Route {
  // Runtime validation
  return path as Route;
}

2. Limit Recursion Depth Explicitly

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

type DeepGet<T, Path extends string, Depth extends number = 10> =
  Depth extends 0
    ? T  // Stop recursion
    : Path extends `${infer Key}.${infer Rest}`
      ? Key extends keyof T
        ? DeepGet<T[Key], Rest, MaxDepth[Depth]>
        : never
      : Path extends keyof T
        ? T[Path]
        : never;

3. Use @ts-expect-error Strategically

// When you know a type is safe but TS can't prove it
// @ts-expect-error - manually verified depth is bounded
type DeepType = RecursiveHelper<Input, 50>;

Debugging Performance Issues

// 1. Check which types are slow
// Use --generateTrace flag
// tsc --generateTrace trace_output

// 2. Simplify and isolate
// Comment out type aliases to find the culprit

// 3. Check instantiation count
// In trace: look for types with high instantiation counts

// 4. Use type-level debugging
type Debug<T> = T;  // Hover to see expanded type
type Test = Debug<ComplexType>;

Key Takeaways

  • Template literal unions multiply combinatorially—A × B × C can explode fast
  • Recursion depth limit is ~50—use accumulator patterns for deep parsing
  • Multiple ambiguous infer causes backtracking—prefer delimited patterns
  • TypeScript caches types, but unique inputs bypass the cache
  • When types get complex, branded types or runtime validation are often better
  • Use --generateTrace to debug slow type checking

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Expert
TypeScript Distributive Conditional Types: The Union Distribution Rule
8 min
TypeScript
Surface
TypeScript's satisfies Operator: Validate Without Widening
6 min

Advertisement