Union Types sind eines der unscheinbarsten und gleichzeitig folgenreichsten Werkzeuge im TypeScript-Repertoire. Sie beantworten eine Frage, die JavaScript permanent stellt, aber nie sauber beantwortet: „Was, wenn ein Wert mehrere Formen annehmen darf?" Eine ID kann eine Zahl oder ein String sein, ein Konfigurationswert ein konkretes Objekt oder das Literal "auto", ein Form-Feld ein gültiger Wert oder null. Mit der Pipe-Syntax A | B modellierst du genau das — und der Compiler zwingt dich freundlich, aber unmissverständlich, vor dem Zugriff zu klären, welche Variante gerade vorliegt. Dieser Artikel zeigt, warum Union eine Vereinigungsmenge ist, warum der Zugriff ohne Narrowing so streng eingeschränkt wirkt — und wann du eine Union besser durch ein Generic oder ein Refactoring ersetzt.
Was Union Types sind
Ein Union Type ist ein Typ, der aus zwei oder mehr anderen Typen zusammengesetzt ist. Ein Wert dieses Typs gehört zu genau einem der Teiltypen — nicht zu allen gleichzeitig, sondern zu einem von ihnen. Die offizielle Handbook-Definition nennt diese Teiltypen Members der Union.
Mathematisch ist das eine Vereinigungsmenge: A | B ist die Menge aller Werte, die entweder in A oder in B (oder in beiden) enthalten sind. Die Pipe | ist bewusst aus der Mengenlehre und der Booleschen Logik geliehen — sie steht für ein logisches ODER auf der Ebene der Typen.
// Pipe-Syntax in ihrer einfachsten Form:
// id darf eine Zahl ODER ein String sein.
function printId(id: number | string): void {
console.log("Deine ID lautet: " + id);
}
printId(42); // ok — gehört zur Menge "number"
printId("abc-123"); // ok — gehört zur Menge "string"
// printId(true); // Fehler — boolean ist kein Member dieser UnionWichtig: die Pipe steht innerhalb der Typ-Annotation, nicht innerhalb des Wertes. 42 | "abc-123" würde JavaScript zur Laufzeit als Bitweise-ODER interpretieren — Union Types existieren ausschließlich zur Compile-Zeit und verschwinden nach dem Transpilieren spurlos.
Eine etwas schönere Schreibweise bei längeren Unions, die du in größeren Codebases häufig siehst, beginnt jede Variante auf eigener Zeile — die führende Pipe vor dem ersten Member ist erlaubt und reine Kosmetik:
// Stilistische Variante für lange Unions —
// die führende Pipe ist optional, aber erhöht die Lesbarkeit.
type LogLevel =
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
const level: LogLevel = "warn"; // okWas wir daraus mitnehmen: Union ist rein deklarativ. Du beschreibst dem Compiler eine Auswahl möglicher Typen, nicht einen kombinierten Typ. Wer einen Wert haben will, der gleichzeitig beide Eigenschaften erfüllt, braucht Intersection — das ist eine andere Operation und Thema eines eigenen Artikels.
Union als Werte-Menge
Der einfachste Weg, Union mental sauber zu fassen, ist die Mengenlehre: jeder Typ ist eine Menge möglicher Werte. number ist die Menge aller Zahlen, string die Menge aller Strings, "asc" | "desc" die Menge mit genau zwei Elementen.
Eine Union A | B ist nichts anderes als die Vereinigung dieser Mengen — der Wertebereich des Resultats umfasst sämtliche Werte aus A und sämtliche Werte aus B.
// string | number — die Vereinigung zweier riesiger Mengen.
// Der konkrete Wert ist immer aus einer der beiden,
// nie aus beiden gleichzeitig.
type Mixed = string | number;
const a: Mixed = "hallo"; // gehört zur string-Teilmenge
const b: Mixed = 99; // gehört zur number-Teilmenge
// const c: Mixed = true; // Fehler — boolean liegt außerhalbAus dieser Mengen-Sicht folgen direkt einige nützliche Regeln. Erstens: die Reihenfolge ist egal — string | number und number | string beschreiben dieselbe Menge. Zweitens: Duplikate kollabieren — string | string ist schlicht string. Drittens: T | never ist immer T, weil never die leere Menge ist und nichts hinzufügt.
Besonders interessant wird der Kontrast zur Intersection A & B. Bei primitiven Typen ist die Schnittmenge fast immer leer — kein einziger Wert ist gleichzeitig Zahl und String:
// Union: Vereinigung — beide Bereiche sind erlaubt.
type U = string | number;
// Intersection: Schnittmenge — leer, daher "never".
// Kein Wert kann gleichzeitig string UND number sein.
type I = string & number; // entspricht: never
// Praktische Folge:
// Eine Variable vom Typ "never" kann nicht zugewiesen werden.
// const x: I = "x"; // Fehler
// const y: I = 42; // FehlerWas wir daraus lernen: Union erweitert den Wertebereich, Intersection engt ihn ein. Die beiden Operationen sind komplementär — bei Objekten kehrt sich das Verhältnis sogar um, wie der Intersection-Artikel zeigt. Bei Primitiven bleibt es bei der Mengen-Intuition: Union vergrößert, Intersection schrumpft (oft auf never).
Was beim Zugriff erlaubt ist
Hier liegt die wichtigste praktische Konsequenz von Unions — und der Punkt, an dem Neulinge am häufigsten stolpern. Wenn ein Wert vom Typ A | B ist, weiß der Compiler nicht, welche der beiden Varianten konkret vorliegt. Er kann daher nur Operationen erlauben, die für beide Varianten gültig sind — also nur das, was im Schnitt der verfügbaren Properties und Methoden liegt.
function describe(value: string | number): string {
// .toString() ist auf beiden Typen verfügbar — ok.
return value.toString();
// .toUpperCase() existiert nur auf string — Fehler.
// return value.toUpperCase();
// Property 'toUpperCase' does not exist on type 'string | number'.
}Das wirkt zunächst überstreng, ist aber das Herzstück der Typsicherheit: würde der Compiler .toUpperCase() durchwinken, würde dein Programm bei jedem describe(42) mit einem Runtime-Fehler explodieren. Die Beschränkung auf gemeinsame Members ist konservativ und korrekt.
Manchmal genügt das schon. Wenn die Members tatsächlich kompatible Methoden teilen, brauchst du gar kein Narrowing:
// Beide Typen besitzen .slice() — der Aufruf ist direkt erlaubt.
// Der Rückgabetyp ist die Union der jeweiligen slice-Returns.
function firstThree(x: number[] | string): number[] | string {
return x.slice(0, 3);
}
firstThree([1, 2, 3, 4]); // [1, 2, 3]
firstThree("hallo welt"); // "hal"Sobald du jedoch auf eine typ-spezifische Methode zugreifen willst, brauchst du Type Narrowing — typeof, instanceof, Array.isArray, das in-Pattern oder einen Discriminator. Die Mechanik dahinter behandelt der Artikel Type Narrowing im Detail; ein kurzer Vorgeschmack:
function describe(value: string | number): string {
if (typeof value === "string") {
// Innerhalb dieses Blocks ist value: string.
return value.toUpperCase();
}
// Im else-Zweig ist value: number — der Compiler
// hat die andere Variante automatisch ausgeschlossen.
return value.toFixed(2);
}Was wir daraus mitnehmen: der Compiler ist nicht restriktiv aus Schikane, sondern aus Korrektheits-Gründen. Sobald du ihm einen Hinweis gibst, welche Variante du gerade hältst, gibt er dir die vollen Properties dieses Members zurück.
Union of Primitives
Die häufigste Art Union in echten Codebases verbindet primitive Typen — string | number, boolean | null, string | undefined. Sie modellieren Werte, deren Form von außen vorgegeben ist und die du nicht selbst kontrollierst: URL-Parameter, Form-Inputs, JSON-Felder, Datenbank-IDs aus Legacy-Systemen.
// Klassisches Beispiel — eine ID kann numerisch (DB) oder
// string-basiert (UUID, externes System) sein.
type EntityId = string | number;
function loadEntity(id: EntityId): void {
// Direkt erlaubt: toString() ist auf beiden Members vorhanden.
const key = id.toString();
console.log("Lade Entität mit Schlüssel:", key);
}
loadEntity(42); // numerisch
loadEntity("550e8400-e29b-41d4-a716"); // UUID-StringEin zweiter klassischer Anwendungsfall ist die Modellierung fehlender Werte vor Aktivierung von strictNullChecks. Selbst mit aktiviertem Strict-Mode bleibt das Pattern für explizit optionale Felder erhalten, nur eben bewusst statt versehentlich:
// Ein Form-Feld, das leer sein darf, bevor der User tippt.
// Die Union sagt explizit: "string ODER 'noch nichts'".
type FormField = string | null;
function isFilled(field: FormField): boolean {
// Equality-Narrowing — vergleicht gegen null
// und entfernt diesen Typ aus der Union.
return field !== null && field.length > 0;
}
isFilled(""); // false
isFilled("Anna"); // true
isFilled(null); // falseAnti-Pattern an dieser Stelle: string | undefined und zusätzlich ? auf demselben Feld. Beides parallel erzeugt einen verwirrenden Typ string | undefined | undefined, der zwar zu string | undefined kollabiert, aber das Modell unklar wirken lässt. Entscheide dich für einen Weg — meistens ist das ?, weil es zusätzlich erlaubt, das Feld komplett wegzulassen.
Was wir daraus lernen: Primitive Unions sind der Brot-und-Butter-Fall — kurz, lesbar, expressiv. Sie eignen sich, sobald derselbe logische Wert in wenigen, abgezählten Formen auftritt und du jede dieser Formen sinnvoll behandeln kannst.
Union of Object-Typen
Sobald Object-Shapes statt Primitiven verknüpft werden, wird Union mächtiger — und ein bisschen tückischer. Du modellierst damit Situationen wie „eine Funktion gibt entweder ein Erfolgs-Ergebnis oder einen Fehler zurück" oder „dieser Event kann ein Click-Event oder ein KeyDown-Event sein".
// Zwei mögliche Antwort-Shapes einer API.
type ApiSuccess = {
data: { id: number; name: string };
// Hinweis: 'error' fehlt hier komplett.
};
type ApiError = {
message: string;
code: number;
// Hinweis: 'data' fehlt hier komplett.
};
type ApiResponse = ApiSuccess | ApiError;
function handle(res: ApiResponse): void {
// res.data — Fehler, existiert nur auf ApiSuccess
// res.message — Fehler, existiert nur auf ApiError
// Nur GEMEINSAME Properties wären zugreifbar —
// die beiden Shapes haben aber keine gemeinsamen.
console.log(res);
}Das demonstriert die Strenge der Common-Member-Regel besonders deutlich: ohne Narrowing kannst du auf keine einzige typ-spezifische Property zugreifen. In der Praxis fügt man darum einen Discriminator ein — eine Literal-Property, anhand derer der Compiler die Varianten unterscheiden kann:
// Discriminator-Property "status" trennt die Varianten sauber.
// Der Compiler nutzt sie automatisch zum Narrowing.
type ApiResponseV2 =
| { status: "ok"; data: { id: number; name: string } }
| { status: "error"; message: string; code: number };
function handleV2(res: ApiResponseV2): void {
if (res.status === "ok") {
// Hier ist res: ApiSuccess-Variante.
console.log(res.data.name);
} else {
// Hier ist res: ApiError-Variante.
console.error(`[${res.code}] ${res.message}`);
}
}Diese Technik heißt Discriminated Union (manchmal auch Tagged Union, Algebraic Data Type) und hat einen eigenen Artikel: Discriminated Unions. Sie ist so zentral, dass sie in TypeScript praktisch das Standard-Pattern für Variant-Modellierung darstellt.
Was wir daraus lernen: Reine Object-Unions ohne Discriminator sind selten praktisch nutzbar — Narrowing fällt schwer, Property-Zugriffe scheitern. Sobald du Object-Varianten brauchst, plane den Discriminator von Anfang an mit ein.
Union of Literal Types
Eine besonders elegante Form der Union verbindet Literal-Typen — einzelne konkrete Werte als Typen. Das Ergebnis ist eine kompakte Enum-Alternative, die ohne den Runtime-Overhead einer echten enum-Deklaration auskommt.
// Sortier-Modus einer Liste: nur zwei Werte sind sinnvoll.
// Die Union wirkt wie ein Mini-Enum, ist aber rein typseitig
// und verschwindet nach dem Compile-Schritt komplett.
type SortOrder = "asc" | "desc";
function sort<T>(items: T[], order: SortOrder): T[] {
const copy = [...items];
copy.sort();
return order === "desc" ? copy.reverse() : copy;
}
sort([3, 1, 2], "asc"); // [1, 2, 3]
sort([3, 1, 2], "desc"); // [3, 2, 1]
// sort([3, 1, 2], "up"); // Fehler — "up" ist kein MemberDiese Pattern-Verwendung dominiert in modernen TS-APIs. React-Component-Props wie variant: "primary" | "secondary" | "ghost", HTTP-Methoden, Theme-Modi, Animation-Easings — überall, wo eine fest definierte Auswahl von String-Werten existiert. Der Vorteil gegenüber enum: weniger Boilerplate, IDE-Autocomplete funktioniert genauso gut, und der Compile-Output enthält keinen zusätzlichen Runtime-Code.
Auch numerische Literal-Unions sind nützlich, etwa für HTTP-Status-Codes oder fest definierte Größen:
// Buttons in deinem Designsystem haben drei feste Größen.
type ButtonSize = 1 | 2 | 3;
function setSize(size: ButtonSize): void {
// Innerhalb der Funktion ist size eine 1, 2 oder 3 —
// niemals 0, niemals 4, niemals 2.5.
console.log("Setze Größe:", size);
}
setSize(2); // ok
// setSize(4); // Fehler — kein Member der UnionWas wir daraus lernen: Literal-Unions sind das Brot-und-Butter-Pattern für API-Modes und Konfigurations-Optionen. Sie verbinden maximale Strenge zur Compile-Zeit mit minimalem Runtime-Aufwand und sind in jeder modernen TS-Codebase allgegenwärtig.
Union mit null und undefined
Vor TypeScript 2.0 waren null und undefined Mitglieder jedes Typs — ein string durfte stillschweigend null sein, ein number durfte undefined sein. Mit dem Compiler-Flag strictNullChecks (Teil von strict: true) hat sich das umgekehrt: null und undefined sind eigene Typen und müssen explizit in eine Union aufgenommen werden, wenn ein Wert sie annehmen darf.
// Ohne explizites null in der Union ist null nicht zuweisbar.
let userName: string;
// userName = null; // Fehler unter strictNullChecks
// Mit null in der Union — explizit erlaubt.
let optionalName: string | null;
optionalName = "Anna";
optionalName = null;
// Eine Variante, die sowohl null als auch "ungesetzt" erlaubt:
let maybeName: string | null | undefined;
maybeName = "Anna";
maybeName = null; // explizit auf null gesetzt
maybeName = undefined; // niemals gesetztDie Unterscheidung zwischen null und undefined ist subtil, aber wichtig. Konvention in vielen Codebases:
undefined— der Wert wurde noch nie gesetzt oder ist optional.null— der Wert wurde bewusst auf „kein Wert" gesetzt (z.B. „User hat das Feld geleert").
In der Praxis greift man oft zu einem nützlichen Trick: == null (mit ==, nicht ===) prüft beide Fälle auf einmal, weil JavaScript null == undefined als true auswertet — und TypeScript kennt diese Sonderregel und narrowt entsprechend:
function greet(name: string | null | undefined): string {
// == null entfernt sowohl null als auch undefined aus der Union.
// Innerhalb des if-Blocks ist name: string.
if (name == null) {
return "Hallo, Gast";
}
return `Hallo, ${name}`;
}Was wir daraus mitnehmen: Unions mit null/undefined sind kein Workaround — sie sind das offizielle Modell für „Wert kann fehlen". strictNullChecks ist heute Standard und fängt die berüchtigte Klasse von „Cannot read properties of null"-Bugs schon zur Compile-Zeit ab.
Inferenz bei Union
TypeScript leitet Unions oft selbst ab — du musst sie nicht immer explizit annotieren. Drei typische Inferenz-Szenarien zeigen, wie das passiert.
Erstens: Ternäre Ausdrücke. Wenn die beiden Zweige unterschiedliche Typen liefern, ist das Ergebnis automatisch eine Union:
// value wird je nach Zufall ein string oder eine number —
// TypeScript inferiert den Typ als string | number.
const value = Math.random() > 0.5 ? "a" : 42;
// Hover-Info: const value: string | numberZweitens: Array-Inferenz. Bei gemischten Array-Literalen sucht der Compiler den Best Common Type — bei unverwandten Members wird das eine Union der Element-Typen:
// TypeScript inferiert das Array als (string | number)[].
const items = ["a", 1, "b", 2];
// Hover-Info: const items: (string | number)[]
// Achtung — bei reinen Literalen ohne Hint
// wird KEINE Literal-Union inferiert, sondern eine
// Union der breiten Typen.
const modes = ["asc", "desc"];
// Hover-Info: string[] — NICHT ("asc" | "desc")[]Wer eine Literal-Union als Inferenz-Ergebnis will, braucht entweder eine explizite Annotation oder as const:
// Mit "as const" werden die Strings als Literal-Typen festgenagelt.
// Das Array selbst wird readonly und elementweise präzise.
const modes = ["asc", "desc"] as const;
// Hover-Info: readonly ["asc", "desc"]
// Daraus eine Literal-Union ableiten:
type Mode = typeof modes[number]; // "asc" | "desc"Drittens: Funktions-Returns mit mehreren Return-Statements. Wenn unterschiedliche Pfade unterschiedliche Typen zurückgeben, ist der Rückgabetyp die Union:
// Kein expliziter Return-Typ — TypeScript inferiert
// den Rückgabetyp als string | number aus den Zweigen.
function pick(flag: boolean) {
if (flag) return "ja";
return 0;
}
// Hover-Info: function pick(flag: boolean): string | numberWas wir daraus mitnehmen: Inferenz kümmert sich oft selbst um Unions, aber sie ist konservativ bei Literalen. Wo du Literal-Präzision brauchst, hilf mit as const oder einer expliziten Annotation nach.
Wann KEINE Union verwenden?
So nützlich Unions sind — es gibt Situationen, in denen sie ein Anti-Pattern sind. Wer das nicht erkennt, baut sich Typen, die zwar formal korrekt sind, aber den Code unleserlich und das Narrowing unzumutbar machen.
Anti-Pattern 1: „Weiß-ich-nicht"-Unions. Wenn du eine Union nur deshalb baust, weil dir der konkrete Typ nicht klar ist, baust du eigentlich ein verstecktes any mit Extra-Schritten:
// Anti-Pattern: zu breite Union "irgendwas davon eben".
// Niemand kann diese Funktion sinnvoll narrowen — es gibt
// zu viele Varianten ohne klaren Discriminator.
function process(
x: string | number | boolean | string[] | { id: number }
): void {
// Innen brauchst du fünf Type Guards.
// Aufrufer haben keine Idee, was sie übergeben sollen.
}Bessere Lösung: Generic oder Refactoring in mehrere spezialisierte Funktionen. Eine Funktion mit klarer Signatur pro Anwendungsfall ist fast immer lesbarer als eine Mega-Funktion mit Union-Argument.
// Variante A — Generic, wenn die Funktion strukturell gleich
// arbeitet, unabhängig vom konkreten Typ.
function passThrough<T>(x: T): T {
return x;
}
// Variante B — getrennte Funktionen mit klarer Domain.
function processUser(u: { id: number }): void { /* ... */ }
function processTag(t: string): void { /* ... */ }
function processFlag(b: boolean): void { /* ... */ }Anti-Pattern 2: Union als Ersatz für Vererbung oder Polymorphismus. Wenn alle Members einer Union ähnliche Methoden bekommen sollten, ist meist ein gemeinsames Interface die bessere Modellierung:
// Statt einer Union mit fast identischen Object-Shapes —
// ein gemeinsames Interface, das alle Varianten erfüllen.
interface Shape {
area(): number;
}
class Circle implements Shape {
constructor(public radius: number) {}
area(): number { return Math.PI * this.radius ** 2; }
}
class Square implements Shape {
constructor(public side: number) {}
area(): number { return this.side ** 2; }
}
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.area(), 0);
}Diese Wahl ist nicht schwarz-weiß: Discriminated Unions sind oft besser als Polymorphismus, weil sie Exhaustiveness-Checks erlauben und keine Klassen-Hierarchien erzwingen. Aber sobald du eine Union schreibst, in der jeder Member dieselben Operationen implementiert, ist ein Interface fast immer die ehrlichere Modellierung.
Anti-Pattern 3: Unions, die der Aufrufer nicht narrowen kann. Wenn deine Funktion ein Union-Ergebnis zurückgibt und der Aufrufer keine Möglichkeit hat zu wissen, welche Variante er bekommen hat, ist die API kaputt. Lieber ein Discriminator-Feld einbauen oder zwei getrennte Funktionen anbieten.
Was wir daraus mitnehmen: Union ist ein präzises Werkzeug für wenige, klar abgegrenzte Varianten. Sobald die Liste lang wird, sobald die Members strukturell ähnlich sind, sobald der Aufrufer keine Chance hat zu unterscheiden — denk an Generics, Interfaces oder ein Refactoring der Signatur.
Interessantes
Union ist eine Vereinigungsmenge — nicht eine Schnittmenge.
A | B beschreibt die Vereinigung der Wertebereiche von A und B. Die Schnittmenge wäre A & B — eine andere Operation mit gegensätzlicher Semantik. Wer „und" meint, will Intersection. Wer „oder" meint, will Union.
Ohne Narrowing nur gemeinsame Properties zugreifbar.
Auf einem Wert vom Typ A | B erlaubt der Compiler ausschließlich Operationen, die auf beiden Members existieren. Das wirkt streng, ist aber korrekt: alles andere wäre eine Lüge gegenüber dem konkret vorliegenden Typ.
string | string ist string — Union ist idempotent.
Duplikate kollabieren stillschweigend. type T = string | string | string ist exakt dasselbe wie type T = string. Das gilt auch für komplexere Typen, solange sie strukturell identisch sind.
T | never ist T — never ist die leere Menge.
Eine Union mit never verändert nichts, weil never die leere Wertmenge bezeichnet und zur Vereinigung nichts beisteuert. Das wird relevant in Conditional Types, die never als „dieser Zweig liefert nichts" zurückgeben.
T | unknown ist unknown — unknown absorbiert.
unknown ist der Top-Typ und enthält alle anderen Typen als Teilmenge. Eine Union mit unknown ist immer wieder unknown — alles andere wäre eine echte Teilmenge davon.
T | any ist any — any zerstört die Union.
any schaltet das Typsystem lokal ab und „infiziert" die ganze Union. Sobald ein Member any ist, kollabiert die Union zu any — du verlierst sämtliche Sicherheitsgarantien der anderen Members. Ein guter Grund, any zu vermeiden, wo es nur geht.
Reihenfolge in der Union ist irrelevant.
string | number und number | string sind exakt derselbe Typ. Das ist mengentheoretisch trivial, aber wichtig zu wissen, weil TypeScript beim Hover und in Fehlermeldungen oft eine eigene, alphabetische oder „display-freundliche" Reihenfolge wählt.
Union kann zur Compile-Zeit zu komplexer Inferenz-Last werden.
Sehr breite Unions (etwa hundert Literal-Members oder tief verschachtelte Object-Unions) erhöhen die Last des Type Checkers messbar. In großen Codebases mit komplexen Type-Tools merkt man das in der IDE — ein guter Hinweis, Unions schmal und gezielt zu halten.
TypeScript sortiert Unions im Hover oft alphabetisch.
Wenn du eine Union deklarierst und dann im Editor über die Variable hoverst, kann die Reihenfolge der Members anders aussehen als in deinem Quelltext. Das ist eine reine Anzeigesache und ändert nichts an der Typ-Identität — aber gut zu wissen, damit man sich nicht wundert.
Weiterführende Ressourcen
Externe Quellen
- Union Types – TypeScript Handbook
- Narrowing – TypeScript Handbook
- TypeScript Playground – Union Examples