A | B für Varianten, A & B für Komposition
Wer TypeScript-Typen nur additiv mit any oder losen Objektformen modelliert, verschenkt die Stärke des Typsystems. Union-Types beschreiben eine von mehreren möglichen Formen, Intersection-Types kombinieren mehrere Formen zu einer. Richtig eingesetzt entstehen daraus API-Modelle und Kompositionen, die der Compiler tatsächlich absichert.
Inhaltsverzeichnis
- 1. Warum any und lose Typisierung nicht ausreichen
- 2. Union-Types: Grundlagen und Narrowing
- 3. Discriminated Unions und Exhaustiveness-Checks
- 4. Intersection-Types: Grundlagen und Objekt-Merging
- 5. Praxis: API-Response-Varianten modellieren
- 6. Praxis: Mixin-artige Komposition mit Intersection-Types
- 7. Häufige Fehler bei Union und Intersection
- 8. Union und Intersection mit Generics und Utility-Types
- 9. Union vs. Intersection vs. Interface Extension im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum any und lose Typisierung nicht ausreichen
Der einfachste Ausweg aus einem unklaren Datenmodell ist any oder ein Objekttyp mit lauter optionalen Feldern. Beides funktioniert kurzfristig, weil der Compiler fast alles durchlässt, aber genau das ist das Problem: Fehler, die eigentlich zur Compile-Zeit auffallen sollten, tauchen erst im Browser oder beim Kunden auf. Ein Response-Objekt mit zehn optionalen Feldern beschreibt nicht, welche Felder tatsächlich zusammen vorkommen, sondern nur, dass theoretisch jedes einzeln fehlen könnte. Das Typsystem wird damit zur Dokumentation ohne Durchsetzungskraft.
Union-Types und Intersection-Types lösen dieses Problem auf zwei unterschiedliche Arten. Ein Union-Type A | B sagt: der Wert ist entweder von Form A oder von Form B, niemals eine beliebige Mischung aus beidem. Ein Intersection-Type A & B sagt: der Wert erfüllt gleichzeitig alle Anforderungen von A und von B. Wer beide Konstrukte gezielt einsetzt, kann reale Zustände, Varianten und Fähigkeiten so modellieren, dass ungültige Kombinationen gar nicht erst kompilieren, statt erst zur Laufzeit mit einem undefined-Fehler aufzufallen.
2. Union-Types: Grundlagen und Narrowing
Die Syntax A | B beschreibt einen Typ, dessen Wert entweder A oder B ist. Klassische Anwendungsfälle sind Statuswerte wie "idle" | "loading" | "success" | "error", IDs, die entweder eine Zahl oder ein String sein dürfen, oder Rückgabewerte von Funktionen, die je nach Eingabe unterschiedliche Objektformen liefern. Solange TypeScript nicht weiß, welche der Varianten konkret vorliegt, stehen einem nur die Eigenschaften zur Verfügung, die in allen Varianten gemeinsam existieren. Das ist beabsichtigt: Der Compiler verhindert damit den Zugriff auf Felder, die in manchen Varianten schlicht nicht existieren.
Um wieder Zugriff auf spezifische Felder zu bekommen, muss der Typ eingeengt werden, ein Vorgang, den man Type Narrowing nennt. Die gängigsten Werkzeuge dafür sind typeof für primitive Typen, instanceof für Klasseninstanzen und der in-Operator für die Prüfung, ob eine Eigenschaft in einem Objekt existiert. Innerhalb des jeweiligen Zweigs kennt TypeScript den eingeengten Typ automatisch und lässt den Zugriff auf die spezifischen Felder zu, ganz ohne manuelle Typ-Assertions.
// Union type: id can be either a number or a string
type EntityId = number | string;
function formatId(id: EntityId): string {
// typeof narrows the union to the specific branch
if (typeof id === "number") {
return `#${id.toString().padStart(6, "0")}`;
}
return id.toUpperCase();
}
// Union of object shapes narrowed with the "in" operator
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
if ("radius" in shape) {
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2;
}
// Remaining branch is narrowed to Square
return shape.side ** 2;
}
class ApiError extends Error {}
class ValidationError extends Error {}
function handleError(error: ApiError | ValidationError): void {
if (error instanceof ApiError) {
console.error("API failure:", error.message);
return;
}
console.warn("Validation failed:", error.message);
}
3. Discriminated Unions und Exhaustiveness-Checks
Eine Discriminated Union ist ein Union-Type, bei dem jede Variante ein gemeinsames Literalfeld besitzt, meist kind oder type genannt, dessen Wert pro Variante eindeutig ist. Dieses eine Feld reicht TypeScript, um in einem switch oder if-Block die gesamte Form des Objekts zu bestimmen, ohne dass man mehrere Felder gleichzeitig prüfen muss. Das Muster ist deutlich robuster als lose Unions aus rein optionalen Feldern, weil jede Variante exakt die Felder trägt, die zu ihr gehören, nicht mehr und nicht weniger.
Der zweite entscheidende Baustein ist die Exhaustiveness-Prüfung mit dem Typ never. Fügt man in den default-Zweig eines switch eine Funktion ein, die einen Parameter vom Typ never erwartet, und übergibt dort den noch nicht behandelten Rest-Typ, meldet der Compiler einen Fehler, sobald eine neue Variante zur Union hinzukommt, aber im switch vergessen wurde. Das verwandelt ein potenziell stilles Laufzeitproblem in einen sofort sichtbaren Compile-Fehler, lange bevor der Code überhaupt getestet wird.
type PaymentMethod =
| { kind: "creditCard"; cardNumber: string; expiry: string }
| { kind: "paypal"; email: string }
| { kind: "invoice"; billingAddress: string };
function describePayment(method: PaymentMethod): string {
switch (method.kind) {
case "creditCard":
return `Card ending in ${method.cardNumber.slice(-4)}`;
case "paypal":
return `PayPal account ${method.email}`;
case "invoice":
return `Invoice to ${method.billingAddress}`;
default:
// Exhaustiveness check: fails to compile if a variant is missing
return assertNever(method);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled payment method: ${JSON.stringify(value)}`);
}
4. Intersection-Types: Grundlagen und Objekt-Merging
Die Syntax A & B erzeugt einen Typ, dessen Werte gleichzeitig alle Anforderungen von A und von B erfüllen müssen. Bei zwei Objekttypen bedeutet das: Das resultierende Objekt muss jede Eigenschaft aus A und jede Eigenschaft aus B besitzen. Das ist besonders nützlich, um wiederkehrende Basisformen, etwa Timestamped mit createdAt und updatedAt, mit spezifischen Domänentypen wie Product oder Order zu kombinieren, ohne die Basisform in jedem einzelnen Typ manuell zu wiederholen.
Wichtig zu verstehen ist, dass sich Intersection-Types bei Objekttypen additiv verhalten, bei primitiven Typen aber destruktiv. { a: string } & { b: number } ergibt ein Objekt mit beiden Feldern, aber string & number ergibt never, weil kein Wert gleichzeitig ein String und eine Zahl sein kann. Überschneiden sich zwei Objekttypen in einer Eigenschaft mit unterschiedlichem Typ, etwa { id: string } und { id: number }, wird die überlappende Eigenschaft selbst wieder zur Intersection der beiden Feldtypen, hier also ebenfalls never, und das Feld kann praktisch nicht mehr sinnvoll befüllt werden.
5. Praxis: API-Response-Varianten modellieren
Ein klassischer Anwendungsfall für Discriminated Unions ist die Modellierung von asynchronen Ladezuständen: Eine Anfrage ist entweder noch nicht gestartet, lädt gerade, war erfolgreich oder ist fehlgeschlagen. Statt eines Objekts mit den optionalen Feldern data, error und isLoading, bei dem theoretisch alle drei Felder gleichzeitig gesetzt sein könnten, beschreibt eine Union mit vier klaren Varianten exakt die Zustände, die im UI tatsächlich vorkommen dürfen. Der große Vorteil zeigt sich beim Rendering: Eine Komponente kann per switch auf status reagieren und weiß in jedem Zweig sicher, welche Felder verfügbar sind.
Für API-Clients lohnt sich dieselbe Technik auf Antwortebene. Ein generischer ApiResult<T>-Typ mit den Varianten success und error zwingt jeden Aufrufer, beide Fälle zu behandeln, bevor er auf data zugreifen darf. Das verhindert die verbreitete Fehlerklasse, bei der ein Response-Objekt aus Versehen als erfolgreich behandelt wird, obwohl das Backend einen Fehlerstatus zurückgegeben hat, weil data in einem locker typisierten Modell trotzdem als vorhanden erscheinen könnte.
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function renderProductState(state: RequestState<Product>): string {
switch (state.status) {
case "idle":
return "Waiting for request";
case "loading":
return "Loading product...";
case "success":
// data is only accessible in this branch
return `Loaded: ${state.data.name}`;
case "error":
return `Failed to load: ${state.error}`;
}
}
interface Product {
id: string;
name: string;
price: number;
}
// Generic API result wraps every backend call in the same shape
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } };
async function fetchProduct(id: string): Promise<ApiResult<Product>> {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
return { ok: false, error: { code: response.status, message: response.statusText } };
}
return { ok: true, data: await response.json() as Product };
}
6. Praxis: Mixin-artige Komposition mit Intersection-Types
Während Union-Types Varianten beschreiben, glänzen Intersection-Types bei der Komposition von Fähigkeiten. Ein typisches Beispiel: Ein Entity-Objekt soll gleichzeitig identifizierbar, zeitgestempelt und versionierbar sein. Statt diese drei Aspekte in einem einzigen großen Interface zu vermischen, definiert man drei kleine, fokussierte Typen und kombiniert sie erst am Verwendungsort mit &. Das entspricht dem Prinzip der Komposition über Vererbung, das aus der objektorientierten Programmierung bekannt ist, nur eben rein auf Typebene ohne Laufzeitkosten.
Diese Technik funktioniert genauso gut mit Funktionen höherer Ordnung, die ein Objekt um zusätzliche Methoden erweitern, ein Muster, das dem klassischen Mixin aus JavaScript sehr nahekommt. Die Rückgabetype-Signatur einer solchen Funktion nutzt &, um dem Aufrufer mitzuteilen, dass das zurückgegebene Objekt sowohl die ursprünglichen als auch die neu hinzugefügten Eigenschaften besitzt. So bleibt der Typ des komponierten Objekts vollständig nachvollziehbar, ohne dass eine explizite gemeinsame Basisklasse nötig wäre.
// Small, focused capability types instead of one large interface
interface Identifiable {
id: string;
}
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Versioned {
version: number;
}
// Composed at the point of use via intersection
type Entity = Identifiable & Timestamped & Versioned;
const order: Entity & { total: number } = {
id: "ord-1029",
createdAt: new Date(),
updatedAt: new Date(),
version: 3,
total: 249.9,
};
// Mixin-style function that composes capabilities at runtime and type level
function withLogging<T extends object>(target: T): T & { log: (msg: string) => void } {
return {
...target,
log(msg: string) {
console.log(`[entity] ${msg}`);
},
};
}
const loggableOrder = withLogging(order);
loggableOrder.log("order created"); // available thanks to the intersection type
7. Häufige Fehler bei Union und Intersection
Der häufigste Fehler ist eine lose Union ohne gemeinsames Diskriminanzfeld, etwa { data?: T; error?: string; isLoading?: boolean } statt einer echten Discriminated Union. Solche Typen lassen sich zwar deklarieren, aber TypeScript kann daraus nicht ableiten, welche Feldkombinationen tatsächlich gültig sind. Das Ergebnis ist Code, der überall manuell auf undefined prüfen muss, obwohl das Typsystem diese Prüfungen eigentlich automatisieren könnte, wenn die Struktur von Anfang an als Discriminated Union modelliert worden wäre.
Ein zweiter verbreiteter Fehler ist die Annahme, Intersection-Types würden bei Konflikten automatisch einen der beiden Typen bevorzugen. string & number ergibt never, nicht etwa string. Wer versehentlich zwei inkompatible primitive Typen schneidet, meist durch eine falsch aufgelöste generische Constraint, bekommt einen Typ, der sich mit keinem realen Wert mehr befüllen lässt, und der Fehler zeigt sich oft erst an einer völlig anderen Stelle im Code, an der der resultierende Typ verwendet wird.
Ein dritter Fallstrick betrifft Excess-Property-Checks bei Intersections aus mehreren Objekttypen mit überlappenden, aber leicht unterschiedlichen optionalen Feldern. TypeScript prüft Objektliterale strenger als Variablen, sodass ein Literal, das direkt einer Intersection zugewiesen wird, teils andere Fehler produziert als eine zuvor deklarierte Variable desselben Typs. Wer diesen Unterschied nicht kennt, wundert sich, warum derselbe Wert einmal einen Fehler wirft und einmal nicht.
8. Union und Intersection mit Generics und Utility-Types
Generics und Unions ergänzen sich besonders gut in Kombination mit den eingebauten Utility-Types Extract und Exclude. Extract<T, U> filtert aus einer Union T alle Varianten heraus, die einer Bedingung U entsprechen, während Exclude<T, U> genau das Gegenteil tut. Bei einer Discriminated Union lässt sich damit gezielt eine einzelne Variante herausziehen, etwa um eine Hilfsfunktion zu schreiben, die nur den Erfolgsfall eines ApiResult verarbeitet, ohne den gesamten Union-Typ erneut auszuschreiben.
NonNullable<T> ist im Kern ebenfalls nur eine spezialisierte Anwendung von Exclude, die null und undefined aus einer Union entfernt. Generische Funktionen, die sowohl mit Union- als auch mit Intersection-Constraints arbeiten, etwa function merge<A, B>(a: A, b: B): A & B, geben dem Aufrufer exakt den kombinierten Typ zurück, ohne dass eine manuelle Typ-Assertion nötig wäre. Das reduziert den Bedarf an as-Casts erheblich, weil der Compiler die Kombination selbst korrekt ableitet.
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } };
// Extract pulls out exactly the success variant of the union
type SuccessResult<T> = Extract<ApiResult<T>, { ok: true }>;
function unwrap<T>(result: SuccessResult<T>): T {
return result.data;
}
// Exclude removes a variant, useful for narrowing down handled states
type Status = "idle" | "loading" | "success" | "error";
type ActiveStatus = Exclude<Status, "idle">;
// Generic merge helper returns the precise intersection type
function merge<A extends object, B extends object>(a: A, b: B): A & B {
return { ...a, ...b };
}
const withDefaults = merge({ theme: "dark" }, { locale: "de-DE" });
// withDefaults: { theme: string } & { locale: string }
9. Union vs. Intersection vs. Interface Extension im Vergleich
In der Praxis stellt sich regelmäßig die Frage, ob eine Struktur mit einer Union, einer Intersection oder klassischer Interface-Vererbung modelliert werden sollte. Die Tabelle fasst die typischen Szenarien und die jeweils robustere Lösung zusammen.
| Szenario | Unsicherer Ansatz | Empfohlenes Pattern | Vorteil |
|---|---|---|---|
| Mehrere Zustände abbilden | Objekt mit lauter optionalen Feldern | Discriminated Union mit "kind"-Feld | Nur gültige Feldkombinationen möglich |
| switch über Varianten | default ohne Prüfung | Exhaustiveness-Check mit never | Fehlt eine Variante, bricht der Build |
| Primitive kombinieren | string & number erwartet | Union string | number verwenden | Vermeidet unbeabsichtigtes never |
| Fähigkeiten kombinieren | Ein großes Interface mit allen Feldern | Kleine Typen per & komponieren | Wiederverwendbar, klar getrennt |
| Feste, offene Hierarchie | Intersection für Vererbung erzwingen | interface extends verwenden | Bessere Fehlermeldungen, Deklarations-Merging |
Als Faustregel gilt: Sobald sich ein Datenmodell in klar unterscheidbare, sich gegenseitig ausschließende Varianten aufteilen lässt, ist eine Discriminated Union die richtige Wahl. Sobald mehrere unabhängige Fähigkeiten oder Eigenschaftsgruppen gleichzeitig auf einem Objekt vorhanden sein müssen, ist eine Intersection die passende Lösung. Für stabile, öffentliche Vertragstypen mit klarer Hierarchie und der Möglichkeit zum späteren Erweitern über declare module bleibt interface extends oft die wartungsfreundlichere Wahl gegenüber einer Intersection aus mehreren Interfaces.
Mironsoft
TypeScript-Tooling, Frontend-Architektur und typsichere Headless-Integrationen
Typsichere Frontend-Architektur für euren Magento-Stack?
Wir modellieren Datenflüsse, API-Clients und Build-Skripte mit sauberen Union- und Intersection-Types, damit Fehler bereits beim Kompilieren auffallen statt beim Kunden im Checkout.
Type-Modeling-Review
Bestehende Typen auf lose Unions und riskante Intersections prüfen
API-Client-Typisierung
Discriminated Unions für Headless- und REST/GraphQL-Anbindungen
Build-Tooling
Strikte tsconfig-Einstellungen und Exhaustiveness-Checks in der CI
10. Zusammenfassung
Union-Types und Intersection-Types lösen zwei unterschiedliche Modellierungsprobleme. Union-Types beschreiben, dass ein Wert eine von mehreren klar unterscheidbaren Formen annimmt, am robustesten als Discriminated Union mit einem gemeinsamen Literalfeld und einem Exhaustiveness-Check über never. Intersection-Types kombinieren mehrere Formen zu einer, ideal für die Komposition kleiner, fokussierter Fähigkeiten statt eines einzigen großen Interfaces. Beide Konstrukte verlagern Fehler, die sonst erst zur Laufzeit auffallen, konsequent in die Compile-Zeit.
Der größte Hebel liegt darin, lose Objekttypen mit ausschließlich optionalen Feldern konsequent durch echte Discriminated Unions zu ersetzen und Intersection-Types nicht für Vererbungshierarchien, sondern für Komposition zu verwenden. Utility-Types wie Extract, Exclude und NonNullable ergänzen beide Konstrukte, ohne dass Union-Typen manuell dupliziert werden müssen. Wer diese Muster konsequent anwendet, bekommt ein Typsystem, das reale Fehler abfängt, statt nur Dokumentation zu simulieren.
Union- und Intersection-Types - Das Wichtigste auf einen Blick
Union-Types (A | B)
Beschreiben eine von mehreren möglichen Formen. Am robustesten als Discriminated Union mit einem gemeinsamen "kind"-Feld.
Intersection-Types (A & B)
Kombinieren mehrere Formen zu einer. Ideal für Komposition kleiner Fähigkeiten, nicht für Vererbungshierarchien.
Exhaustiveness-Check
Eine never-Funktion im default-Zweig lässt den Build fehlschlagen, sobald eine Variante vergessen wurde.
Typische Fallstricke
Lose Unions ohne Diskriminante, string & number ergibt never, überlappende Felder mit unterschiedlichem Typ.