Distributive Conditional Types sind eines der mächtigsten — und am meisten missverstandenen — Features im TypeScript-Typsystem. Die Regel ist simpel: Wenn ein Conditional Type der Form T extends U ? X : Y einen naked Type Parameter prüft und T eine Union ist, wird der Check über jedes Union-Member einzeln verteilt und das Resultat wieder zu einer Union zusammengeführt. Genau dieser Mechanismus macht Utility-Types wie Exclude, Extract und NonNullable überhaupt erst möglich. Auffallend wird Distribution meist erst, wenn ein selbstgeschriebener Conditional Type unerwartete Ergebnisse liefert — etwa weil ein IsUnion<T>-Check plötzlich immer boolean statt true zurückgibt. Wer den Mechanismus einmal verinnerlicht hat und den Wrap-Trick [T] extends [U] im Werkzeugkasten hat, beherrscht eine der zentralen Techniken fortgeschrittener Type-Level-Programmierung.

Was Distribution ist

Ein Conditional Type ist ein Typ-Ausdruck der Form T extends U ? X : Y: Wenn T zu U zuweisbar ist, ergibt sich X, sonst Y. So weit, so symmetrisch zu einem JavaScript-Ternary — nur eben auf Typ-Ebene.

Spannend wird es, sobald T ein generischer Type Parameter ist und mit einer Union aufgerufen wird. Statt den Check einmal mit der gesamten Union durchzuführen, wendet TypeScript ihn auf jedes Union-Member einzeln an und vereint die Resultate wieder. Genau das nennt sich Distribution.

ts
// Klassisches Beispiel aus dem Handbook
type ToArray<T> = T extends any ? T[] : never;

type A = ToArray<string>;
// → string[]

type B = ToArray<string | number>;
// Erwartung naiv: (string | number)[]
// Realität:       string[] | number[]
//
// Schritt für Schritt:
//   ToArray<string | number>
// = ToArray<string> | ToArray<number>   // Distribution
// = string[]        | number[]

Das Ergebnis unterscheidet sich substantiell: (string | number)[] ist ein Array mit gemischten Elementen — string[] | number[] ist entweder ein reines String- ODER ein reines Number-Array. Distribution behält die ursprüngliche Trennung der Union-Member bei.

Naked Type Parameter — die Bedingung für Distribution

Distribution passiert nur unter einer Bedingung: Der geprüfte Typ muss ein naked Type Parameter sein — also ein Type-Variable, der unverpackt und direkt links vom extends steht. Sobald der Parameter in einem anderen Konstrukt eingebettet ist, schaltet TypeScript die Distribution ab und behandelt die Union als Ganzes.

ts
// Naked → distribuiert
type Naked<T>      = T extends string ? "yes" : "no";

// Nicht naked → distribuiert NICHT
type Wrapped<T>    = [T] extends [string] ? "yes" : "no";
type Boxed<T>      = Promise<T> extends Promise<string> ? "yes" : "no";
type ArrayWrap<T>  = T[] extends string[] ? "yes" : "no";

type N = Naked<string | number>;
// "yes" | "no"  — distribuiert: ("yes") | ("no")

type W = Wrapped<string | number>;
// "no"  — als Ganzes geprüft: (string | number) extends string? Nein.

„Naked" heißt wörtlich: ohne Wrapper. T ja, [T] nein, Promise<T> nein, { v: T } nein. Sobald der Compiler eine Hülle um den Parameter sieht, deferiert er den Vergleich und prüft die Union als atomare Einheit.

Wann ist Distribution gewollt?

Distribution ist die Basis für Filter-Operationen auf Unions. Die TypeScript-eigenen Utility-Types Exclude, Extract und NonNullable funktionieren ausschließlich, weil der Conditional Type über jedes Union-Member einzeln läuft und ein Treffer mit never markiert oder behalten wird. Ohne Distribution wäre das nicht ausdrückbar — der Check würde immer die Union als Ganzes vergleichen und entweder true oder false ergeben.

ts
// Aus lib.es5.d.ts (vereinfacht):
type Exclude<T, U>     = T extends U ? never : T;
type Extract<T, U>     = T extends U ? T : never;
type NonNullable<T>    = T extends null | undefined ? never : T;

Die drei Definitionen haben dieselbe Struktur: T (die zu filternde Union) steht naked links, der Filter-Typ U rechts. Distribution sorgt dafür, dass jedes Member einzeln getestet wird — entweder fliegt es raus (never) oder bleibt drin.

Exclude und Extract als kanonische Beispiele

Am besten lässt sich Distribution Schritt für Schritt nachvollziehen. Hier Exclude<&quot;a&quot; | &quot;b&quot; | &quot;c&quot;, &quot;a&quot;&gt;:

ts
type R = Exclude<"a" | "b" | "c", "a">;

// Auflösung:
//   Exclude<"a" | "b" | "c", "a">
// = ("a" extends "a" ? never : "a")     // → never
//   | ("b" extends "a" ? never : "b")   // → "b"
//   | ("c" extends "a" ? never : "c")   // → "c"
// = never | "b" | "c"
// = "b" | "c"                            // never ist neutral in Unions

Der entscheidende letzte Schritt: never ist das neutrale Element für Union-Typen — never | X ergibt X. Genau deshalb funktioniert die Filterung: Treffer werden zu never reduziert und verschwinden aus der finalen Union.

Extract macht exakt das Gegenteil: Treffer bleiben, Nicht-Treffer werden zu never.

ts
type Events = "click" | "hover" | "focus" | "blur";
type FocusEvents = Extract<Events, "focus" | "blur">;
// → "focus" | "blur"

// Distribution:
//   ("click" extends "focus" | "blur" ? "click" : never)  // never
// | ("hover" extends "focus" | "blur" ? "hover" : never)  // never
// | ("focus" extends "focus" | "blur" ? "focus" : never)  // "focus"
// | ("blur"  extends "focus" | "blur" ? "blur"  : never)  // "blur"

Beide Utilities sind in lib.es5.d.ts genau so definiert. Wer Distribution verstanden hat, kann sie auch selbst schreiben.

Wann ist Distribution UNGEWOLLT?

Es gibt eine Klasse von Checks, die explizit die Union als Ganzes prüfen sollen — nicht jedes Member einzeln. Genau dort wird Distribution zur Falle. Klassiker: ein Utility, der herausfinden soll, ob ein Typ eine Union ist.

ts
// Erster, naiver Versuch — funktioniert NICHT
type IsUnionBroken<T> = T extends T ? true : false;

type X = IsUnionBroken<string>;          // true
type Y = IsUnionBroken<string | number>; // boolean  (!)

// Warum boolean? Distribution:
//   IsUnionBroken<string | number>
// = (string extends string | number ? true : false)
//   | (number extends string | number ? true : false)
// = true | true
// = true
//
// Tatsächlich liefert "T extends T" hier true — aber:
// bei einem komplexeren Check wäre das Resultat oft true | false = boolean.

Das ist der Klassiker: T extends T sieht aus wie eine Tautologie, ist aber bei einer Union ein Auslöser für Distribution. Der eigentlich gewollte Check — „ist die Union als Ganzes eine Union?" — kommt nie zustande, weil der Compiler die Union schon im ersten Schritt zerlegt.

Der Wrap-Trick [T] extends [U]

Die Standard-Lösung: Wrap beide Seiten in ein Tupel der Größe 1. Ein Tupel ist invariant in seinem Element-Typ — die Union wird damit aus der Position des naked Type Parameter herausgenommen und als Ganzes behandelt.

ts
// OHNE Wrap: distribuiert
type ExtendsString<T> = T extends string ? true : false;

type A = ExtendsString<"a" | 1>;
// boolean — Distribution:
//   "a" extends string ? true : false   // true
// | 1   extends string ? true : false   // false
// → true | false → boolean

// MIT Wrap: distribuiert NICHT
type ExtendsStringStrict<T> = [T] extends [string] ? true : false;

type B = ExtendsStringStrict<"a" | 1>;
// false — als Ganzes geprüft:
// ["a" | 1] extends [string]?  Nein, weil 1 nicht string ist.

type C = ExtendsStringStrict<"a" | "b">;
// true — ["a" | "b"] extends [string]?  Ja.

Wichtig: Das Tupel muss beide Seiten umfassen — [T] extends U reicht nicht, weil dann auf der rechten Seite ein anderer Strukturtyp steht und der Check fehlschlägt. Konvention ist [T] extends [U].

Andere Wraps funktionieren ebenfalls ([T][0], { x: T } extends { x: U }), sind aber unidiomatisch. Tupel-Wrap ist der etablierte Standard.

Praxis: IsUnion<T>

Mit dem Wrap-Trick lässt sich ein funktionierender Union-Detector bauen. Die Idee: Distribution gezielt zulassen für eine Vergleichsoperation, und über den Wrap eine zweite Referenz auf dieselbe Union mitführen, die nicht distribuiert.

ts
// U bleibt als Ganzes (nicht naked durch das Copy-Konstrukt),
// T distribuiert — wenn T eine Union ist, ist ein Single-Member
// niemals gleich der vollen Union U.
type IsUnion<T, U = T> =
  T extends any
    ? [U] extends [T]
      ? false   // U als Ganzes ist Single-Member T → keine Union
      : true    // U als Ganzes ist breiter als T → Union
    : never;

type R1 = IsUnion<string>;          // false
type R2 = IsUnion<string | number>; // true
type R3 = IsUnion<never>;           // never  (leere Distribution)

// Walk-through für IsUnion<string | number>:
// Distribution über T:
//   T = string:
//     [U] extends [T] → [string | number] extends [string] → false
//     → Branch: true
//   T = number:
//     [U] extends [T] → [string | number] extends [number] → false
//     → Branch: true
// → true | true → true

Der Default U = T ist der Kniff: Beim ersten Aufruf zeigen T und U auf denselben Typ. Distribution greift dann nur über TU bleibt unangetastet als Referenz auf die volle Union. Der Wrap [U] extends [T] vergleicht beide ohne erneute Distribution.

Praxis: NonNullable<T>

NonNullable ist die Utility, die null und undefined aus einem Typ entfernt. Sie ist seit TypeScript 4.8 direkt im Compiler als Intrinsic implementiert, war aber davor genau so geschrieben:

ts
type NonNullable<T> = T extends null | undefined ? never : T;

type R = NonNullable<string | null | number | undefined>;
// → string | number

// Schritt für Schritt:
//   NonNullable<string | null | number | undefined>
// = (string    extends null | undefined ? never : string)    // string
//   | (null      extends null | undefined ? never : null)      // never
//   | (number    extends null | undefined ? never : number)    // number
//   | (undefined extends null | undefined ? never : undefined) // never
// = string | never | number | never
// = string | number

Auch hier ist der Schlüssel: never verschwindet aus Unions. Distribution liefert für jedes Member einen Beitrag — entweder das Member selbst oder never. Die finale Union enthält nur die behaltenen Member.

never als Spezialfall — die leere Distribution

never ist die leere Union — eine Union ohne Member. Distribution über never ergibt also nichts: Das Resultat ist wieder never. Das ist meistens das gewünschte Verhalten, aber gelegentlich überraschend.

ts
type ToArray<T> = T extends any ? T[] : never;

type R = ToArray<never>;
// → never  (NICHT never[])
//
// Erklärung: Distribution über never iteriert über keine Member.
// Das Resultat ist eine Vereinigung von 0 Beiträgen → never.

// Wenn du never[] willst, brauchst du den Wrap-Trick:
type ToArrayStrict<T> = [T] extends [any] ? T[] : never;

type R2 = ToArrayStrict<never>;
// → never[]

Dieselbe Mechanik erklärt, warum Exclude<never, ...> immer never ergibt — es gibt keine Member, über die distribuiert werden könnte. Und warum Exclude<T, never> einfach T zurückliefert: never ist als Filter-Typ leer, kein Member wird gefangen.

ts
type A = Exclude<never, string>;   // never  (Distribution über 0 Member)
type B = Exclude<string, never>;   // string (kein Member matched never)
type C = Exclude<"a" | "b", never>;// "a" | "b"

Debugging-Tipp: Hover-Anzeige und Playground

Distributive Conditional Types werden im IDE-Hover oft nicht expandiert — du siehst dann nur den Namen des Type-Alias, nicht das aufgelöste Resultat. Im TypeScript-Playground steht dir ein zusätzliches Werkzeug zur Verfügung: der Type-Inspector in der rechten Sidebar zeigt die voll aufgelöste Form.

ts
type ToArray<T> = T extends any ? T[] : never;

type R = ToArray<string | number>;
//   ^? hover zeigt: string[] | number[]
//
// Im VS-Code-Hover steht manchmal nur "ToArray<string | number>".
// Trick: Eine Identitäts-Resolution erzwingen:

type Expand<T> = T extends T ? T : never;
type R2 = Expand<ToArray<string | number>>;
//   ^? jetzt garantiert expandiert

Bei komplexen Distribution-Ketten hilft auch das Auseinanderziehen in Zwischen-Aliases: Jeder Schritt bekommt einen eigenen Typ, jeder lässt sich einzeln hovern. Das ist der „Print-Debugging"-Ansatz für Type-Level-Code.

KonstruktDistribuiert?Hinweis
T extends U ? X : Y (T naked)JaStandard-Distribution
[T] extends [U] ? X : YNeinTupel-Wrap, der Klassiker
Promise<T> extends Promise<U>NeinT ist nicht naked
T[] extends U[]NeinT ist eingebettet
&#123; v: T &#125; extends &#123; v: U &#125;NeinT in Objekt-Property
T extends T ? ... : ...JaBei Unions: Identität pro Member
Exclude<T, U>, Extract<T, U>JaBeruhen auf Distribution
NonNullable<T>JaFiltert null/undefined per Distribution

Häufige Stolperfallen

Distribution passiert NUR bei naked Type Parameters.

Die Regel ist hart: T extends U mit naked T distribuiert, alles andere nicht. Sobald T in einer Hülle steht — Array, Promise, Tupel, Objekt — schaltet der Compiler die Distribution ab und prüft die Union als atomare Einheit. Das ist gleichzeitig die Grundlage des Wrap-Tricks: [T] extends [U] verhindert Distribution durch genau diesen Mechanismus.

T extends U ja, Promise extends Promise nein.

Schon der harmloseste Wrapper killt die Distribution: Promise<T> extends Promise<U> ? X : Y behandelt T als nicht-naked, weil es in einem generischen Konstrukt sitzt. Das ist oft unbeabsichtigt — etwa wenn man Async-Returns prüfen will und sich wundert, warum das Resultat keine Union mehr ist. Lösung: erst auspacken (per infer), dann prüfen.

[T] extends [U] ist der Standard-Wrap-Trick.

Tupel-Wrap der Größe 1 ist die idiomatische Variante. Tupel sind invariant in ihrem Element-Typ, was den Vergleich „als Ganzes" sauber macht. Andere Wraps funktionieren technisch ({ x: T } extends { x: U }, (() => T) extends (() => U)), sind aber unüblich und können bei Variance-Subtleties anders reagieren.

Exclude filtert via Distribution — ohne wäre es eine Ja-Nein-Frage.

Ohne Distribution wäre Exclude<"a" | "b", "a"> nicht "b", sondern "a" | "b" oder never — je nach Vergleich der gesamten Union mit "a". Distribution macht aus dem binären Check eine echte Filter-Operation: Jedes Member wird einzeln getestet und entweder behalten oder zu never reduziert. Das ist die zentrale Mechanik aller Filter-Utilities.

never distribuiert zu never — die leere Distribution.

never ist die Union ohne Member. Distribution iteriert über 0 Member, das Resultat ist never. Praktische Folge: ToArray<never> ergibt never statt never[]. Wer das vermeiden will, nutzt den Wrap-Trick — [T] extends [any] hängt nicht von der Member-Iteration ab und liefert dann never[]. Im Alltag ist das Default-Verhalten meistens richtig (Filter über leere Union = leer).

T extends T ist NICHT die Identität, wenn T eine Union ist.

Sieht aus wie eine Tautologie, ist aber tatsächlich ein Distribution-Trigger. T extends T ? X : Y distribuiert über jedes Union-Member und führt X pro Member aus. Das ist gleichzeitig ein häufig genutztes Idiom, um Distribution gezielt zu erzwingen — etwa um X als Funktion auf jedes Member anzuwenden („map über eine Union").

IDE-Hover zeigt das resolvierte Resultat oft nicht expandiert.

VS Code und WebStorm hovern Conditional-Type-Resultate manchmal nur als Alias-Name, ohne die voll aufgelöste Form. Der TypeScript-Playground (typescriptlang.org/play) hat einen besseren Inspector. Workaround in der eigenen Codebase: einen Expand<T> = T extends T ? T : never-Typ definieren und das Resultat dort durchreichen — erzwingt die Expansion zur Hover-Zeit.

Distribution kann bei großen Unions die Compile-Zeit bremsen.

Jede Distribution erzeugt N Sub-Checks für N Union-Member. Verschachtelte Distributions multiplizieren sich — bei 50er-Unions in mehreren Layern wird der Compiler spürbar langsam. Gegenmittel: Distribution dort verhindern, wo sie nicht gebraucht wird (Wrap-Trick), oder Zwischen-Aliases einsetzen, damit der Compiler cachen kann.

Exclude ergibt T — never ist neutral beim Excludieren.

Der Filter-Typ U = never matched kein Member von T, also bleiben alle erhalten. Spiegelbildlich: Exclude<never, T> ergibt never, weil über 0 Member iteriert wird. Das macht never zum neutralen Element bei Union-Operationen — nützlich für default-Type-Parameter und Edge-Cases in eigenen Utility-Types.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Union & Intersection

Zur Übersicht