Rekursive Conditional Types in TypeScript: Tiefe Typtransformationen
AI generated
<T>
type
TypeScript · Typsystem · Advanced Types
Rekursive Conditional Types in TypeScript
tiefe Typtransformationen ohne Absturz des Compilers

Ein einzelner Conditional Type prüft eine Bedingung einmal. Rekursive Conditional Types wenden dieselbe Bedingung wieder und wieder auf sich selbst an, bis eine Abbruchbedingung erreicht ist, und verwandeln damit verschachtelte Objekte, Tupel und Strings vollständig zur Compile-Zeit. Wer die Mechanik, das Rekursionslimit und das Tail-Rekursion-Muster kennt, kann DeepReadonly, Tupel-Reverse oder rekursive Template Literal Types robust selbst schreiben.

18 Min. Lesezeit Conditional Types · Rekursion · infer · Tupel TypeScript 4.5+ · 5.x

1. Was rekursive Conditional Types wirklich lösen

Ein normaler Conditional Type wie T extends U ? X : Y trifft genau eine Entscheidung. Sobald ein Typ aber mehrere Ebenen verschachtelt ist, etwa ein Objekt mit Objekten mit Arrays mit weiteren Objekten, reicht eine einmalige Prüfung nicht mehr aus. Genau hier kommen rekursive Conditional Types ins Spiel: Der Typ ruft sich innerhalb seiner eigenen Definition selbst wieder auf, mit einem kleineren oder tiefer verschachtelten Teiltyp als Argument, bis eine Abbruchbedingung erreicht ist.

Das Prinzip ist dasselbe wie bei rekursiven Funktionen zur Laufzeit, nur dass hier der TypeScript-Compiler zur Compile-Zeit rekursiv Typen auswertet statt Werte. Seit TypeScript 4.1 sind rekursive Conditional Types offiziell ohne Workarounds erlaubt, seit 4.5 wurde die Tail-Rekursions-Optimierung eingeführt, die viele bis dahin unmögliche Muster praktikabel machte. Wer diese Mechanik versteht, kann Utility-Typen wie DeepPartial, DeepReadonly oder rekursive String-Parser selbst bauen, statt sie aus einer Bibliothek zu kopieren, ohne die Grenzen zu kennen.

In den folgenden Abschnitten bauen wir schrittweise von der Grundidee über konkrete Utility-Typen bis zum Rekursionslimit und den Grenzen der Technik auf. Jedes Beispiel ist lauffähiger TypeScript-Code, den man direkt im Playground testen kann, um das Verhalten von rekursiven Conditional Types selbst nachzuvollziehen.

2. Grundbausteine: Conditional Types und das Rekursionsprinzip

Bevor man rekursive Conditional Types schreibt, muss die nicht-rekursive Grundform sitzen. type IsString<T> = T extends string ? true : false prüft einmal, ob T dem Constraint string entspricht, und liefert das Ergebnis als Literal-Typ. Eine Rekursion entsteht, sobald der true- oder false-Zweig nicht direkt ein Ergebnis liefert, sondern den Typ selbst wieder aufruft, meist mit einem strukturell kleineren Teil des Eingabetyps.

Damit eine Rekursion terminiert, braucht jeder rekursive Conditional Type eine klare Abbruchbedingung, analog zur Basisfall-Prüfung einer rekursiven Funktion. Bei Objekttypen ist das häufig die Prüfung, ob ein Feld ein primitiver Typ ist, bei Tupeln die Prüfung auf ein leeres Tupel [], bei Strings die Prüfung auf einen leeren String "". Fehlt diese Abbruchbedingung, meldet der Compiler entweder eine Endlosschleife oder das gleich im nächsten Abschnitt beschriebene Tiefenlimit.


// 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. Praxisbeispiel: DeepReadonly für verschachtelte Objekte

Ein klassisches Anwendungsfeld für rekursive Conditional Types ist das Absichern von Konfigurationsobjekten gegen versehentliche Mutation. TypeScripts eingebautes Readonly<T> macht nur die oberste Ebene unveränderlich, verschachtelte Objekte bleiben mutierbar. Ein eigener DeepReadonly-Typ löst das, indem er sich rekursiv durch jede Eigenschaft des Objekts arbeitet und dabei Arrays, Funktionen und primitive Typen als Abbruchbedingungen behandelt.

Der entscheidende Punkt bei diesem rekursiven Conditional Type ist die Fallunterscheidung: Ein Array darf nicht wie ein normales Objekt behandelt werden, sonst verliert man die Tupel- oder Array-Semantik, und Funktionen dürfen nicht rekursiv weiterverarbeitet werden, weil sie keine iterierbaren Eigenschaften im gewünschten Sinn besitzen. Diese drei Fallunterscheidungen, primitiver Typ, Array und generisches Objekt, decken in der Praxis fast alle Konfigurationsstrukturen ab.


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-Rekursion: Akkumulator-Pattern für den Compiler

Ab TypeScript 4.5 erkennt der Compiler bestimmte Formen von Tail-Rekursion und wertet sie ohne wachsenden Stack aus, ähnlich wie Tail-Call-Optimierung in funktionalen Laufzeiten. Damit ein rekursiver Conditional Type als Tail-Rekursion erkannt wird, muss der rekursive Aufruf die letzte Operation im jeweiligen Zweig sein, ohne dass danach noch etwas mit dem Ergebnis geschieht. Ein häufiges Muster dafür ist ein Akkumulator-Parameter, der das Zwischenergebnis mitträgt statt es nach dem rekursiven Aufruf weiterzuverarbeiten.

Das folgende Beispiel zeigt, wie ein Tupel-zu-Union-Typ mit und ohne Akkumulator aussieht. Ohne Akkumulator wächst mit jeder Rekursionsebene ein Ausdruck wie X | Rest<...>, der Compiler muss sich den gesamten Auswertungsbaum merken. Mit Akkumulator wird das Ergebnis direkt weitergereicht, was bei langen Tupeln oder Strings den Unterschied zwischen einer erfolgreichen Kompilierung und dem in Abschnitt 5 beschriebenen Tiefenlimit ausmacht.


// 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. Das Rekursionslimit: "Type instantiation is excessively deep"

TypeScript begrenzt die Rekursionstiefe von Conditional Types bewusst, um den Compiler vor Endlosschleifen und übermäßigem Speicherverbrauch zu schützen. Wird die Grenze überschritten, meldet der Compiler den Fehler "Type instantiation is excessively deep and possibly infinite". Dieser Fehler bedeutet nicht zwingend, dass der Typ tatsächlich unendlich ist, sondern oft nur, dass die konkrete Instanz zu viele Rekursionsebenen benötigt, etwa bei sehr langen Tupeln oder tief verschachtelten JSON-Strukturen.

Die praktische Lösung besteht meist aus drei Bausteinen: erstens das im vorherigen Abschnitt gezeigte Akkumulator-Muster nutzen, um die Rekursion tail-rekursiv zu halten, zweitens die Eingabegröße künstlich begrenzen, etwa mit einem Zähler-Parameter, der nach einer festen Anzahl Iterationen abbricht, und drittens in Grenzfällen bewusst auf any oder eine flachere Repräsentation ausweichen, statt den Compiler mit einer theoretisch korrekten, aber praktisch nicht auswertbaren Typdefinition zu belasten.


// 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. Rekursive Template Literal Types für Stringtransformationen

Template Literal Types lassen sich ebenso rekursiv verwenden wie Objekt- oder Tupeltypen, weil sie intern über infer Zeichen für Zeichen zerlegt werden können. Ein typisches Beispiel ist die Umwandlung von snake_case in camelCase auf Typebene, komplett ohne Laufzeitcode. Der rekursive Conditional Type zerlegt den String am ersten Unterstrich, verarbeitet den Rest rekursiv weiter und setzt die Teile mit angepasster Groß- und Kleinschreibung wieder zusammen.

Diese Technik ist besonders nützlich, wenn man API-Antworten mit snake_case-Feldern typsicher in ein camelCase-Frontend-Modell überführen will, ohne die Umbenennung manuell für jedes Feld zu pflegen. Der Typ garantiert, dass Umbenennung und tatsächliche Laufzeit-Transformation synchron bleiben, weil beide auf derselben Zerlegungslogik beruhen.


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. Rekursive Tupel-Typen: Length und Reverse

Tupel eignen sich besonders gut für rekursive Conditional Types, weil sie über Pattern Matching mit [infer First, ...infer Rest] Element für Element zerlegt werden können, ähnlich wie Listen in funktionalen Sprachen. Ein Length-Typ, der die Anzahl der Elemente eines Tupels als Literal-Zahl liefert, ist ein einfaches, aber lehrreiches Beispiel, weil TypeScript die native Eigenschaft ["length"] zwar direkt anbietet, die rekursive Variante aber zeigt, wie man beliebige Eigenschaften über die Tupelstruktur berechnen kann.

Komplexere Beispiele wie Zip<T, U>, das zwei Tupel elementweise zu Paaren kombiniert, oder Chunk<T, N>, das ein Tupel in Blöcke fester Größe aufteilt, folgen demselben Muster: Zerlege das erste Element, rufe dich rekursiv mit dem Rest auf, kombiniere das Ergebnis. Solche Typen sind in typsicheren Funktions-Signaturen nützlich, etwa wenn eine Funktion garantiert dieselbe Anzahl Rückgabewerte wie Eingabeparameter haben muss.


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. Rekursive Typen in echten Bibliotheken

Rekursive Conditional Types sind kein akademisches Feature, sondern die Grundlage populärer TypeScript-Bibliotheken. Zod definiert seine Schema-Inferenz über tief rekursive Conditional Types, damit z.infer<typeof schema> auch verschachtelte Objekte, Arrays und Unions korrekt in TypeScript-Typen übersetzt. tRPC nutzt rekursive Typen, um einen kompletten Router-Baum mit beliebiger Verschachtelungstiefe typsicher an den Client durchzureichen, ohne dass Entwickler manuell Typdefinitionen pflegen müssen.

Auch type-fest, eine reine Utility-Type-Bibliothek ohne Laufzeitcode, besteht praktisch vollständig aus rekursiven Conditional Types wie PartialDeep, ReadonlyDeep oder Paths, das alle möglichen Property-Pfade eines verschachtelten Objekts als Union von Template Literal Types berechnet. Wer die in diesem Artikel gezeigten Muster verinnerlicht hat, kann den Quellcode dieser Bibliotheken lesen, ohne von der Rekursionstiefe überrascht zu werden.

9. Rekursive vs. iterative Typtransformation im Vergleich

Nicht jede Aufgabe braucht einen rekursiven Conditional Type. Für flache Transformationen reichen Mapped Types ohne Rekursion oft aus und sind leichter zu lesen. Die folgende Tabelle stellt die gängigen Ansätze für Typtransformationen gegenüber und zeigt, wann sich der zusätzliche Aufwand einer rekursiven Definition lohnt.

Aufgabe Ohne Rekursion Mit rekursivem Conditional Type Empfehlung
Flache Objekt-Readonly Readonly<T> Unnötiger Overhead Eingebauten Utility-Typ nutzen
Verschachtelte Objekt-Readonly Nicht abbildbar DeepReadonly<T> Rekursiver Conditional Type nötig
Feste Anzahl Verschachtelungen Manuell ausgeschrieben Rekursion mit Tiefenlimit Rekursion, aber mit Zähler
Sehr lange Tupel/Strings Fehlerhafte Ergebnisse Risiko: Tiefenlimit erreicht Tail-Rekursion + Akkumulator
Einfache Union-Filterung Exclude<T, U> Unnötiger Overhead Distributive Conditional Type reicht

Die Tabelle macht deutlich: Rekursive Conditional Types entfalten ihren Wert bei echter struktureller Verschachtelung, nicht bei flachen Transformationen. Wer einen rekursiven Typ für ein flaches Problem schreibt, erkauft sich unnötige Komplexität und ein höheres Risiko, gegen das Tiefenlimit zu laufen, ohne einen tatsächlichen Vorteil gegenüber einem einfachen Mapped Type zu erhalten.

Mironsoft

TypeScript-Architektur, Typsystem-Beratung und Refactoring

Komplexe Typen, die sich weigern zu kompilieren?

Wir analysieren bestehende Typdefinitionen, lösen "excessively deep"-Fehler mit tail-rekursiven Mustern und bauen wartbare, rekursive Utility-Typen für eure Codebasis.

Typsystem-Audit

Analyse bestehender Conditional Types auf Rekursionsrisiken und Tiefenlimits

Utility-Type-Bibliothek

Maßgeschneiderte DeepPartial, DeepReadonly und Zip-Typen für euren Datenbestand

Performance-Tuning

Tail-Rekursion und Depth-Limits, damit tsc auch bei großen Projekten schnell bleibt

10. Zusammenfassung

Rekursive Conditional Types lösen ein Problem, das flache Conditional Types und Mapped Types nicht abdecken können: die vollständige, typsichere Transformation beliebig tief verschachtelter Strukturen. Das Prinzip ist immer dasselbe, der Typ ruft sich selbst mit einem strukturell kleineren Teiltyp auf, bis eine Abbruchbedingung erreicht ist, egal ob bei Objekten, Tupeln oder Template Literal Strings. Seit TypeScript 4.5 erkennt der Compiler tail-rekursive Muster und wertet sie effizienter aus als klassische Rekursion mit Nachbearbeitung.

Das Rekursionslimit "Type instantiation is excessively deep" ist kein Bug, sondern eine bewusste Schutzgrenze. Wer Akkumulator-Parameter, Tiefenzähler und die Unterscheidung zwischen tail-rekursiven und nicht tail-rekursiven Mustern beherrscht, kann rekursive Typen für DeepReadonly, Tupel-Transformationen oder String-Parsing schreiben, die auch bei realistischen Datenmengen zuverlässig kompilieren, so wie es Zod, tRPC und type-fest in der Praxis vormachen.

Rekursive Conditional Types — Das Wichtigste auf einen Blick

Grundprinzip

Ein Conditional Type ruft sich selbst mit einem strukturell kleineren Teiltyp auf, bis eine Abbruchbedingung wie ein primitiver Typ oder ein leeres Tupel erreicht ist.

Tail-Rekursion

Ein Akkumulator-Parameter trägt das Zwischenergebnis, sodass der rekursive Aufruf die letzte Operation bleibt. Ab TypeScript 4.5 optimiert der Compiler solche Muster.

Tiefenlimit

"Type instantiation is excessively deep" tritt bei zu vielen Rekursionsebenen auf. Ein Tiefenzähler als Tupel-Parameter begrenzt die Rekursion kontrolliert.

Praxisbeispiele

DeepReadonly, DeepPartial, SnakeToCamel und Zip zeigen dieselbe Mechanik in unterschiedlichen Kontexten, von Objekten über Strings bis zu Tupeln.

11. FAQ: Rekursive Conditional Types in TypeScript

1Was ist ein rekursiver Conditional Type?
Ein Conditional Type, der sich selbst mit einem kleineren Teiltyp aufruft, bis eine Abbruchbedingung erreicht ist. So werden beliebig tief verschachtelte Strukturen transformiert.
2Seit wann erlaubt?
Offiziell seit TypeScript 4.1. Ab 4.5 erkennt der Compiler tail-rekursive Muster und wertet sie effizienter aus.
3Was bedeutet excessively deep?
Das Rekursionslimit des Compilers wurde erreicht, oft bei langen Tupeln oder tiefen Objekten. Kein Beweis für Unendlichkeit, meist ein Zeichen für zu viele Ebenen.
4Was ist Tail-Rekursion hier?
Der rekursive Aufruf ist die letzte Operation, meist über einen Akkumulator-Parameter. TypeScript 4.5+ wertet solche Muster effizienter aus.
5DeepReadonly selbst schreiben?
Fallunterscheidung zwischen Array, Funktion und Objekt, bei Objekten rekursiv jede Eigenschaft erneut umschließen, primitive Typen als Abbruch unverändert lassen.
6Rekursionstiefe begrenzen?
Ein Tupel-Parameter als Zähler, der bei jedem Aufruf wächst und bei fester Länge abbricht, verhindert unkontrollierte Rekursion.
7Rekursion bei Template Literal Types?
Ja, über infer wird ein String in Kopf und Rest zerlegt, der Rest rekursiv weiterverarbeitet, komplett ohne Laufzeitcode.
8Warum nutzt Zod so viel Rekursion?
Schema-Definitionen sind naturgemäß verschachtelt, nur rekursive Conditional Types übersetzen diese Verschachtelung vollständig in TypeScript-Typen.
9Performance-Risiko für den Compiler?
Bei tiefer Verschachtelung ohne Tail-Rekursion ja. Akkumulator-Muster sind deutlich günstiger und vermeiden wachsende Auswertungsbäume.
10Wann auf Rekursion verzichten?
Bei flachen Transformationen ohne echte Verschachtelung reicht ein einfacher Mapped Type oder eingebauter Utility-Typ völlig aus.