Recursive Conditional Types in TypeScript: Deep Type Transformations
AI generated
<T>
type
TypeScript · Type System · Advanced Types
Recursive Conditional Types in TypeScript
deep type transformations without crashing the compiler

A single conditional type checks a condition once. Recursive conditional types apply the same condition to themselves again and again until a base case is reached, turning nested objects, tuples, and strings into fully transformed types at compile time. Once you know the mechanics, the depth limit, and the tail recursion pattern, you can write DeepReadonly, tuple reverse, or recursive template literal types confidently on your own.

18 min read Conditional Types · Recursion · infer · Tuples TypeScript 4.5+ · 5.x

1. What recursive conditional types actually solve

A normal conditional type such as T extends U ? X : Y makes exactly one decision. But once a type is nested across several levels, an object containing objects containing arrays containing further objects, a single check is no longer enough. That is exactly where recursive conditional types come in: the type calls itself again inside its own definition, with a smaller or more deeply nested sub-type as the argument, until a base case is reached.

The principle is the same as with recursive functions at runtime, except here the TypeScript compiler evaluates types recursively at compile time instead of values. Since TypeScript 4.1, recursive conditional types have been officially allowed without workarounds, and since 4.5 a tail recursion optimization was introduced that made many previously impossible patterns practical. Understanding this mechanism lets you build utility types like DeepPartial, DeepReadonly, or recursive string parsers yourself instead of copying them from a library without knowing their limits.

The following sections build up step by step, from the basic idea through concrete utility types to the recursion limit and the boundaries of the technique. Every example is runnable TypeScript code you can test directly in the playground to see the behavior of recursive conditional types yourself.

2. Building blocks: conditional types and the recursion principle

Before writing recursive conditional types, the non-recursive base form has to be solid. type IsString<T> = T extends string ? true : false checks once whether T matches the constraint string and returns the result as a literal type. A recursion arises once the true or false branch does not deliver a result directly but calls the type again on itself, usually with a structurally smaller part of the input type.

For a recursion to terminate, every recursive conditional type needs a clear base case, analogous to the base case check of a recursive function. For object types this is often a check whether a field is a primitive type, for tuples a check for an empty tuple [], for strings a check for an empty string "". If this base case is missing, the compiler either reports an infinite loop or the depth limit described in the next section.


// Non-recursive: checks the condition exactly once
type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

// Recursive: the type calls itself again on a smaller sub-type
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> } // recursive call on each property
  : T; // base case: primitives are returned unchanged

interface Config {
  server: { host: string; port: number; tls: { enabled: boolean } };
  name: string;
}

// Every nested level becomes optional, not just the top level
type PartialConfig = DeepPartial<Config>;

3. Practical example: DeepReadonly for nested objects

A classic use case for recursive conditional types is protecting configuration objects against accidental mutation. TypeScript's built in Readonly<T> only makes the top level immutable, nested objects remain mutable. A custom DeepReadonly type solves this by recursively working through every property of the object, treating arrays, functions, and primitive types as base cases.

The crucial point of this recursive conditional type is the case distinction: an array must not be treated like a plain object, otherwise you lose the tuple or array semantics, and functions must not be processed recursively, because they do not have iterable properties in the desired sense. These three cases, primitive type, array, and generic object, cover almost every configuration structure encountered in practice.


type DeepReadonly<T> =
  T extends (infer U)[]
    ? ReadonlyArray<DeepReadonly<U>>            // arrays keep array semantics
    : T extends (...args: unknown[]) => unknown
      ? T                                        // functions are left untouched
      : T extends object
        ? { readonly [K in keyof T]: DeepReadonly<T[K]> } // recurse into objects
        : T;                                     // base case: primitives

interface AppState {
  user: { id: number; roles: string[] };
  flags: { darkMode: boolean };
}

const state: DeepReadonly<AppState> = {
  user: { id: 1, roles: ["admin"] },
  flags: { darkMode: true },
};

// state.user.id = 2;        // Error: read-only property
// state.user.roles.push("x"); // Error: read-only array method

4. Tail recursion: the accumulator pattern for the compiler

Since TypeScript 4.5, the compiler recognizes certain forms of tail recursion and evaluates them without a growing stack, similar to tail call optimization in functional runtimes. For a recursive conditional type to be recognized as tail recursive, the recursive call has to be the last operation in its branch, with nothing further done to the result afterwards. A common pattern for this is an accumulator parameter that carries the intermediate result along instead of processing it after the recursive call returns.

The following example shows a tuple to union type with and without an accumulator. Without an accumulator, an expression like X | Rest<...> grows with every recursion level, and the compiler has to remember the entire evaluation tree. With an accumulator, the result is passed forward directly, which for long tuples or strings makes the difference between a successful compile and the depth limit described in section 5.


// Non tail-recursive: result is wrapped after the recursive call returns
type ReverseSlow<T extends unknown[]> = T extends [infer First, ...infer Rest]
  ? [...ReverseSlow<Rest>, First] // work happens AFTER the recursive call
  : T;

// Tail-recursive: accumulator carries the result, no extra work afterwards
type ReverseFast<T extends unknown[], Acc extends unknown[] = []> =
  T extends [infer First, ...infer Rest]
    ? ReverseFast<Rest, [First, ...Acc]> // recursive call IS the final step
    : Acc;

type R1 = ReverseFast<[1, 2, 3, 4, 5]>; // [5, 4, 3, 2, 1]

5. The recursion limit: "Type instantiation is excessively deep"

TypeScript deliberately limits the recursion depth of conditional types to protect the compiler from infinite loops and excessive memory usage. When the limit is exceeded, the compiler reports the error "Type instantiation is excessively deep and possibly infinite". This error does not necessarily mean the type is actually infinite, it often just means the concrete instance needs too many recursion levels, for example with very long tuples or deeply nested JSON structures.

The practical fix usually consists of three building blocks: first, use the accumulator pattern shown in the previous section to keep the recursion tail recursive, second, artificially bound the input size, for example with a counter parameter that stops after a fixed number of iterations, and third, in edge cases deliberately fall back to any or a flatter representation instead of burdening the compiler with a theoretically correct but practically unevaluatable type definition.


// Depth-limited recursion using a tuple as an iteration counter
type Increment<T extends unknown[]> = [...T, unknown];

type DeepFlatten<T, Depth extends unknown[] = []> =
  Depth["length"] extends 10 // hard stop after 10 recursion levels
    ? T
    : T extends readonly (infer U)[]
      ? DeepFlatten<U, Increment<Depth>>
      : T;

type Flat = DeepFlatten<number[][][]>; // number, resolved within the depth limit

6. Recursive template literal types for string transformations

Template literal types can be used recursively just like object or tuple types, because internally they can be decomposed character by character using infer. A typical example is converting snake_case into camelCase at the type level, entirely without runtime code. The recursive conditional type splits the string at the first underscore, recursively processes the remaining part, and reassembles the pieces with adjusted casing.

This technique is especially useful when you want to translate API responses with snake_case fields into a typesafe camelCase frontend model, without maintaining the renaming manually for every field. The type guarantees that the renaming and the actual runtime transformation stay in sync, because both rely on the same decomposition logic.


type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}` // recurse on the remaining tail
    : S; // base case: no more underscores left

type CamelKeys<T> = {
  [K in keyof T as K extends string ? SnakeToCamel<K> : K]: T[K];
};

interface ApiUser {
  user_id: number;
  first_name: string;
  last_login_at: string;
}

type FrontendUser = CamelKeys<ApiUser>;
// { userId: number; firstName: string; lastLoginAt: string }

7. Recursive tuple types: Length and Reverse

Tuples are particularly well suited for recursive conditional types, because they can be decomposed element by element via pattern matching with [infer First, ...infer Rest], similar to lists in functional languages. A Length type that returns the number of elements of a tuple as a literal number is a simple but instructive example, because while TypeScript already offers the native ["length"] property directly, the recursive variant shows how to compute arbitrary properties from the tuple structure.

More complex examples such as Zip<T, U>, which combines two tuples element wise into pairs, or Chunk<T, N>, which splits a tuple into blocks of fixed size, follow the same pattern: split off the first element, recursively call yourself on the rest, combine the result. Such types are useful in typesafe function signatures, for example when a function must guarantee the same number of return values as input parameters.


type Length<T extends unknown[]> = T["length"];

type Zip<T extends unknown[], U extends unknown[]> =
  T extends [infer THead, ...infer TRest]
    ? U extends [infer UHead, ...infer URest]
      ? [[THead, UHead], ...Zip<TRest, URest>] // pair current heads, recurse on rests
      : []
    : [];

type Pairs = Zip<[1, 2, 3], ["a", "b", "c"]>;
// [[1, "a"], [2, "b"], [3, "c"]]

type L = Length<[1, 2, 3, 4]>; // 4

8. Recursive types in real world libraries

Recursive conditional types are not an academic feature, they are the foundation of popular TypeScript libraries. Zod defines its schema inference through deeply recursive conditional types, so that z.infer<typeof schema> correctly translates nested objects, arrays, and unions into TypeScript types. tRPC uses recursive types to pass a complete router tree of arbitrary nesting depth through to the client in a typesafe way, without developers having to maintain type definitions by hand.

type-fest, a pure utility type library without runtime code, is also built almost entirely on recursive conditional types such as PartialDeep, ReadonlyDeep, or Paths, which computes every possible property path of a nested object as a union of template literal types. Once you have internalized the patterns shown in this article, you can read the source of these libraries without being surprised by their recursion depth.

9. Recursive vs. iterative type transformation compared

Not every task needs a recursive conditional type. For flat transformations, mapped types without recursion are often sufficient and easier to read. The following table compares common approaches for type transformations and shows when the extra effort of a recursive definition pays off.

Task Without recursion With recursive conditional type Recommendation
Flat object readonly Readonly<T> Unnecessary overhead Use the built in utility type
Nested object readonly Not representable DeepReadonly<T> A recursive conditional type is needed
Fixed number of nesting levels Manually spelled out Recursion with a depth limit Recursion, but with a counter
Very long tuples/strings Incorrect results Risk: depth limit reached Tail recursion + accumulator
Simple union filtering Exclude<T, U> Unnecessary overhead A distributive conditional type is enough

The table makes it clear: recursive conditional types earn their value with real structural nesting, not with flat transformations. Writing a recursive type for a flat problem buys you unnecessary complexity and a higher risk of hitting the depth limit, without gaining any real advantage over a simple mapped type.

Mironsoft

TypeScript architecture, type system consulting, and refactoring

Complex types that refuse to compile?

We analyze existing type definitions, resolve "excessively deep" errors with tail recursive patterns, and build maintainable recursive utility types for your codebase.

Type system audit

Analysis of existing conditional types for recursion risks and depth limits

Utility type library

Tailor made DeepPartial, DeepReadonly, and Zip types for your data

Performance tuning

Tail recursion and depth limits so tsc stays fast even on large projects

10. Summary

Recursive conditional types solve a problem that flat conditional types and mapped types cannot cover: the complete, typesafe transformation of arbitrarily deeply nested structures. The principle is always the same, the type calls itself with a structurally smaller sub-type until a base case is reached, whether for objects, tuples, or template literal strings. Since TypeScript 4.5 the compiler recognizes tail recursive patterns and evaluates them more efficiently than classic recursion with post-processing.

The recursion limit "Type instantiation is excessively deep" is not a bug, it is a deliberate safety boundary. Once you master accumulator parameters, depth counters, and the distinction between tail recursive and non tail recursive patterns, you can write recursive types for DeepReadonly, tuple transformations, or string parsing that compile reliably even on realistic data volumes, exactly as Zod, tRPC, and type-fest demonstrate in practice.

Recursive Conditional Types — the essentials at a glance

Core principle

A conditional type calls itself with a structurally smaller sub-type until a base case such as a primitive type or an empty tuple is reached.

Tail recursion

An accumulator parameter carries the intermediate result so the recursive call remains the last operation. Since TypeScript 4.5 the compiler optimizes such patterns.

Depth limit

"Type instantiation is excessively deep" occurs with too many recursion levels. A depth counter as a tuple parameter bounds the recursion in a controlled way.

Practical examples

DeepReadonly, DeepPartial, SnakeToCamel, and Zip all show the same mechanism in different contexts, from objects to strings to tuples.

11. FAQ: Recursive Conditional Types in TypeScript

1What is a recursive conditional type?
A conditional type that calls itself with a smaller sub-type until a base case is reached. This transforms arbitrarily deeply nested structures.
2Since when allowed?
Officially since TypeScript 4.1. Since 4.5 the compiler recognizes tail recursive patterns and evaluates them more efficiently.
3What does excessively deep mean?
The compiler's recursion limit was reached, often with long tuples or deep objects. Not proof of infinity, usually a sign of too many levels.
4What is tail recursion here?
The recursive call is the last operation, usually via an accumulator parameter. TypeScript 4.5+ evaluates such patterns more efficiently.
5Writing DeepReadonly myself?
Case distinction between array, function, and object, recursively wrap every property again for objects, leave primitive types unchanged as the base case.
6Limiting recursion depth?
A tuple parameter as a counter that grows with every call and stops at a fixed length prevents uncontrolled recursion.
7Recursion with template literal types?
Yes, via infer a string is split into head and remainder, the remainder is processed recursively, entirely without runtime code.
8Why does Zod use so much recursion?
Schema definitions are naturally nested, only recursive conditional types translate this nesting completely into TypeScript types.
9Performance risk for the compiler?
With deep nesting without tail recursion, yes. Accumulator patterns are significantly cheaper and avoid growing evaluation trees.
10When to avoid recursion?
For flat transformations without real nesting, a simple mapped type or built in utility type is entirely sufficient.