Union, Intersection und Narrowing in TypeScript
1. Übersicht
Union und Intersection sind zwei grundlegende Werkzeuge für TypeScript, um Daten flexibler als ein einfaches interface zu beschreiben. Narrowing ist eine Technik, die dem Compiler hilft, den spezifischen Typ eines Werts in jedem Code-Zweig zu „verstehen”, anstatt dich manuell zu zwingen, ihn zu casten (as).
Dieser Artikel beginnt mit Konzepten von Union/Intersection, führt durch populäre Narrowing-Techniken (typeof, instanceof, in, discriminated union) bis hin zum Schreiben eigener Type Guards. Danach weißt du, wann du Union verwendest, wann Intersection, und wie du Fehler wie Object is possibly undefined oder überall verstreutes as any vermeidest.
2. Union type
Union (|) bedeutet, dass ein Wert einer von mehreren möglichen Typen sein kann.
type PaymentMethod = "cash" | "card" | "e-wallet";
function describe(method: PaymentMethod): string {
return `Zahlung per: ${method}`;
}
describe("card"); // OK
// describe("paypal"); // Compile-Fehler: nicht in der Union enthalten
PaymentMethod akzeptiert nur 3 spezifische String-Literale. Wenn du einen anderen Wert übergibst, meldet TypeScript sofort einen Fehler zur Compile-Zeit, nicht erst zur Laufzeit.
Union funktioniert auch mit Object-Typen:
interface CashPayment {
type: "cash";
amount: number;
}
interface CardPayment {
type: "card";
amount: number;
cardNumber: string;
}
type Payment = CashPayment | CardPayment;
Jetzt kann eine Variable vom Typ Payment entweder CashPayment oder CardPayment sein, aber TypeScript erlaubt dir nur, auf die gemeinsamen Felder beider Typen zuzugreifen (hier type und amount), wenn du noch nicht verengt hast.
function printAmount(p: Payment) {
console.log(p.amount); // OK, gemeinsames Feld
// console.log(p.cardNumber); // Fehler: existiert nicht auf CashPayment
}
Hinweis: Union kombiniert Felder nicht zusammen. Um auf spezifische Felder zuzugreifen, musst du zuerst narrowen (siehe Abschnitt 4).
3. Intersection type
Intersection (&) bedeutet, dass ein Wert gleichzeitig alle kombinierten Typen erfüllen muss.
interface Identifiable {
id: string;
}
interface Timestamped {
createdAt: Date;
}
type Order = Identifiable & Timestamped & {
customerName: string;
total: number;
};
const order: Order = {
id: "ORD-001",
createdAt: new Date(),
customerName: "Nguyễn Văn A",
total: 150_000,
};
Order hier hat alle Felder von Identifiable, Timestamped und dem zusätzlichen Object-Literal. Fehlt ein Feld, meldet der Compiler einen Fehler.
Intersection ist nützlich, wenn du mehrere kleine Mixins zu einem großen Typ „kombinieren” möchtest, ähnlich wie Komposition in OOP, aber auf Type-Ebene.
type AuditInfo = { updatedBy: string };
type AuditedOrder = Order & AuditInfo;
const audited: AuditedOrder = {
...order,
updatedBy: "admin",
};
3.1. Intersection mit konfliktierenden primitiven Typen
Wenn zwei Typen in einer Intersection in ihren primitiven Typen konfliktieren, ist das Ergebnis never:
type Impossible = string & number; // Typ never, kein Wert kann dies erfüllen
Das ist normalerweise ein Zeichen, dass du & anstatt | eingegeben hast.
4. Narrowing: Typen in Code-Branches präzisieren
Narrowing ist der Prozess, bei dem TypeScript den Typ automatisch präzisiert, basierend auf Bedingungsprüfungen im Code (if, switch, typeof…).
4.1. Narrowing mit typeof
Verwende dies für Unions mit primitiven Typen.
function formatId(id: string | number): string {
if (typeof id === "number") {
return id.toFixed(0); // id wird zu number verengt
}
return id.toUpperCase(); // id wird zu string verengt
}
Im if-Branch weiß TypeScript, dass id vom Typ number ist, daher kannst du .toFixed() aufrufen. Im impliziten else-Branch ist id nur noch string.
4.2. Narrowing mit instanceof
Verwende dies für Unions mit Klassen.
class ValidationError extends Error {
constructor(public field: string) {
super(`Field ${field} is invalid`);
}
}
function handleError(err: Error | ValidationError): void {
if (err instanceof ValidationError) {
console.log(`Validierungsfehler in Feld: ${err.field}`);
} else {
console.log(`Anderer Fehler: ${err.message}`);
}
}
err.field existiert nur auf ValidationError, daher erlaubt der Compiler dir nur, darauf im überprüften instanceof-Branch zuzugreifen.
4.3. Narrowing mit in
Verwende dies, wenn sich die Typen in der Union durch unterschiedliche Felder voneinander unterscheiden (ohne ein gemeinsames Discriminant-Feld).
interface Dog {
bark: () => void;
}
interface Cat {
meow: () => void;
}
function makeSound(pet: Dog | Cat): void {
if ("bark" in pet) {
pet.bark(); // wird zu Dog verengt
} else {
pet.meow(); // wird zu Cat verengt
}
}
Der in-Operator prüft, ob eine Property zur Laufzeit auf einem Objekt existiert, und TypeScript nutzt das Ergebnis, um den Typ entsprechend zu verengen.
4.4. Discriminated Union — der sicherste Weg
Das ist das häufigste Muster in echtem Code: Füge ein gemeinsames Literal-Feld hinzu (oft kind oder type genannt), um die „Branches” in der Union zu unterscheiden.
interface SuccessResult {
status: "success";
data: string;
}
interface ErrorResult {
status: "error";
message: string;
}
type ApiResult = SuccessResult | ErrorResult;
function handleResult(result: ApiResult): void {
switch (result.status) {
case "success":
console.log(`Daten: ${result.data}`);
break;
case "error":
console.log(`Fehler: ${result.message}`);
break;
}
}
Der Compiler nutzt das status-Feld als „Discriminant”: im case "success" wird result zu SuccessResult verengt; im case "error" zu ErrorResult. Das ist die von TypeScript empfohlene Methode für alle Unions mit mehr als 2 Branches, da switch auch Exhaustiveness-Checks unterstützt (Abschnitt 4.6).
4.5. Narrowing mit Truthy-Prüfung und optionalem Chaining
interface Customer {
name: string;
phone?: string;
}
function callCustomer(customer: Customer): void {
if (customer.phone) {
// customer.phone wird von string | undefined zu string verengt
console.log(`Anruf unter: ${customer.phone}`);
} else {
console.log("Kunde hat keine Telefonnummer");
}
}
Die Truthy-Prüfung (if (customer.phone)) eliminiert undefined (und leere Strings, aber das spielt bei Telefonnummern keine Rolle).
4.6. Exhaustiveness-Check mit never
Wenn du einen neuen Branch zu einer Union hinzufügst und vergisst, ihn zu behandeln, möchtest du, dass der Compiler sofort einen Fehler meldet, statt eines stillen Bugs zur Laufzeit. Verwende einen Typ never im default-Branch:
function assertUnreachable(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function handleResultSafe(result: ApiResult): string {
switch (result.status) {
case "success":
return result.data;
case "error":
return result.message;
default:
return assertUnreachable(result); // wenn du einen neuen Branch hinzufügst und vergisst, ihn zu behandeln, ist hier ein Compile-Fehler
}
}
Wenn später jemand PendingResult zu ApiResult hinzufügt und die entsprechende case vergisst, ist result im default-Branch kein never mehr, und der Compiler meldet einen Fehler beim Build — kein Warten auf Bugs in Production.
5. Benutzerdefinierte Type Guards
Wenn die Prüflogik komplexer als ein einfacher Ausdruck ist, kannst du eine Funktion schreiben, die x is T zurückgibt, um Narrowing wiederzuverwenden.
interface PaidOrder {
status: "paid";
paidAt: Date;
}
interface PendingOrder {
status: "pending";
}
type OrderState = PaidOrder | PendingOrder;
function isPaid(order: OrderState): order is PaidOrder {
return order.status === "paid";
}
function printReceipt(order: OrderState): void {
if (isPaid(order)) {
console.log(`Bezahlt um: ${order.paidAt.toISOString()}`);
} else {
console.log("Noch nicht bezahlt");
}
}
order is PaidOrder ist ein Type Predicate. Der Compiler vertraut auf das Ergebnis der Funktion isPaid, um order im if-Branch zu PaidOrder zu verengen, auch wenn die Prüflogik in einer separaten Funktion liegt.
6. Verifikation durch Tests
Narrowing ist eine Compile-Time-Funktion, daher ist „Testing” wirklich nur das Ausführen von tsc ohne Fehler. Aber du solltest trotzdem Tests für das entsprechende Runtime-Verhalten schreiben.
npm install -D vitest typescript
import { describe, it, expect } from "vitest";
function totalAmount(payment: CashPayment | CardPayment): number {
return payment.amount;
}
describe("Payment narrowing", () => {
it("berechnet den Betrag für Bargeldzahlung korrekt", () => {
const cash: CashPayment = { type: "cash", amount: 50_000 };
expect(totalAmount(cash)).toBe(50_000);
});
it("berechnet den Betrag für Kartenzahlung korrekt", () => {
const card: CardPayment = {
type: "card",
amount: 200_000,
cardNumber: "4111111111111111",
};
expect(totalAmount(card)).toBe(200_000);
});
});
Führe npx vitest run aus, um folgende Ergebnisse zu erhalten:
✓ Payment narrowing > berechnet den Betrag für Bargeldzahlung korrekt
✓ Payment narrowing > berechnet den Betrag für Kartenzahlung korrekt
Test Files 1 passed (1)
Tests 2 passed (2)
7. Häufige Fehler und Antipattern
- Missbrauch von
aszur Vermeidung von Narrowing: Wenn du dich dabei ertappst, ständig zu casten (as SomeType), um den Compiler zu „besänftigen”, ist wahrscheinlich dein Union/Type Guard falsch designt. Gehe zurück und verwende discriminated unions. - Intersection zweier Interfaces mit gleichnamigem Feld aber unterschiedlichem Typ: TypeScript versucht zu mergen, aber wenn Typen konfligieren (z.B. eine Seite
string, die anderenumber), wird das Feldnever— sehr anfällig für verwirrende Fehler. Nutze immer einheitliche Feldnamen beim Design von Mixins. - Union zu breit (z.B.
string | number | boolean | object): Das zwingt überall in deinem Code zu Narrowing, was zu unordentlichem Code führt. Wenn eine Union mehr als 4–5 unzusammenhängende Branches hat, überdenke dein Domain Model — wahrscheinlich solltest du es in mehrere separate Funktionen/Overloads aufteilen. default-Branch mitneverinswitchvergessen: Du verlierst den Vorteil der Exhaustiveness-Prüfung und kannst leicht einen Branch vergessen, wenn du die Union erweiterst.in-Operator auf Objekten mit komplexer Prototype-Chain:inprüft auch geerbte Properties, was bei Klassen mit tiefem Inheritance zu unerwartetem Narrowing-Verhalten führt. In diesem Fall sindinstanceofoder discriminant Felder zuverlässiger.
Hinweis: Narrowing funktioniert nur gut, wenn TypeScript es durch statischen Control Flow beweisen kann. Wenn du einen verengten Wert durch einen asynchronen Callback (
setTimeout, Promise.then) einer anderen Variable zuweist, kann der verengte Typ „vergessen” werden — TypeScript verfolgt nicht absolut über Closure-Grenzen in allen Fällen.
8. Fazit
Union beschreibt „einer aus vielen Typen”, Intersection beschreibt „alle Typen gleichzeitig” — diese beiden Werkzeuge sind die Grundlage für den Aufbau einer korrekten Domain-Modellierung in TypeScript. Narrowing (typeof, instanceof, in, discriminated union, benutzerdefinierte Type Guards) ermöglicht es dir, Unions sicher zu nutzen, ohne manuell zu casten.
Faustregel: Bevorzuge discriminated unions ab 2 oder mehr Branches, ergänze immer einen Exhaustiveness-Check mit never in switch-Statements, und vermeide Intersections zwischen Typen mit gleichnamigen Feldern aber unterschiedlichen Typen. Richtig angewendet, wirst du viele logische Fehler schon während des Schreibens fangen, bevor sie in Production landen.