Christoph Haag
Christoph ist Softwareentwickler mit einer Passion für Musik
01.10.2025 | 7 min Lesezeit
Wenn du schon einmal eine Datenstruktur wie die folgende gesehen hast, dann ist dieser Blogpost für dich:
type DataState = {
initial: boolean;
isLoading: boolean;
hasError: boolean;
data: Data | null;
};
Jedes Property kann zwei Werte annehmen. Damit ergeben sich 2⁴ = 16 mögliche Zustände. Der Großteil der Zustände ergibt keinen Sinn. Zum Beispiel:
const invalidState: DataState = {
initial: true,
isLoading: true,
hasError: true,
data: { /* ... */ }
}
Beim Setzen eines neuen Zustands wird das Dilemma besonders deutlich. Es ist schwierig den Überblick über die gültigen Zustände und ihre Zustandsübergänge zu behalten.
const setLoading = () => {
setState((cur) =>
cur.isLoading
? cur
: {
initial: false,
isLoading: true,
hasError: false,
// Do we need a property to indicate whether to keep data or reset it?
data: cur.data, // or null?
}
);
// or just set the property and keep everything else?
setState((cur) => ({ ...cur, isLoading: true }));
};
An der Verwendung in folgender React-Komponente wird der eigentlich Use-Case klar. Es gibt genau vier mögliche Zustände: 'initial', 'loading', 'error' und 'success'.
const Component = () => {
const theState = useTheState();
return theState.initial ? (
<RenderInitialState />
) : theState.isLoading ? (
<RenderLoadingState />
) : theState.hasError ? (
<RenderErrorState />
) : theState.data ? (
<RenderData data={theState.data} />
) : (
<RenderInvalidState />
);
};
Die Datenstruktur ermöglicht Fehler. Also werden Fehler passieren. Vielleicht nicht jetzt. Vielleicht nicht von dir. Aber garantiert irgendwann. Wir wollen die Datenstruktur also so gestalten, dass sie a) dem Use-Case entspricht und somit b) unmögliche Zustände unmöglich macht.
Union Types ermöglichen es, unabhängige Typen in einer Entweder-Oder-Semantik zu kombinieren. Eine Variable kann mehrere Typen annehmen, aber nur einen davon zu einem bestimmten Zeitpunkt. In TypeScript wird ein Union Type mit dem '|'-Operator definiert.
type UnionType = TypeA | TypeB | TypeC;
type StringOrNumber = string | number;
Eine spezielle Form der Union Types sind Discriminated Unions oder Tagged Unions. Der Type wird durch ein gemeinsames Property (discriminator oder tag) unterschieden, das den Typ eindeutig identifiziert.
// tag = 'kind'
type Shape = { kind: 'circle'; radius: number } | { kind: 'rectangle'; width: number; height: number };
// tag = 'success'
type Result<TData, TError> = { success: true; data: TData } | { success: false; error: TError };
Union Types ermöglichen es, alle Zustände erschöpfend (exhaustive) zu überprüfen. Das bedeutet, dass zur compile-Zeit sichergestellt wird, dass alle möglichen Zustände berücksichtigt werden.
type Status = "initial" | "loading" | "error" | "success";
const status: Status = "initial";
switch (status) {
case "initial": {
// do something
break;
}
case "loading": {
// do something
break;
}
case "error": {
// do something
break;
}
case "success": {
// do something
break;
}
default: {
// This will never be reached, but TypeScript will ensure that all cases are handled.
// If a new status is added, TypeScript will throw an error here.
const _exhaustiveCheck: never = status;
}
}
Werden Zustände hinzugefügt oder entfernt, gibt TypeScript einen Fehler aus und der Entwickler ist gezwungen, den Code anzupassen. Wird im Beispiel ein Status 'empty' hinzugefügt, so erscheint der Fehler 'Type "empty" is not assignable to type "never"'. Das ist sicher nicht der sprechendste Fehler, aber immernoch besser als ein unbemerkter Fehler zur Laufzeit.
Wenden wir das Konzept der Union Types auf unser ursprüngliches Beispiel an, so ergibt sich folgende Datenstruktur:
type DataState =
| { kind: "initial" }
| { kind: "loading" }
| { kind: "error"; error: Error }
| { kind: "success"; data: Data };
Weder beim Setzen, noch beim Lesen können Fehler passieren. Soll auf data zugegriffen werden, muss vorher auf kind geprüft werden. Soll ein Fehler gesetzt werden, muss kind auf error gesetzt werden. Das property data ist dann per Definition nicht mehr verfügbar.
Jedem Zustand können beliebig viele zustandsspezifische Properties zugeordnet werden, ohne anderen Zuständen in die Quere zu kommen. Die Anzahl an möglichen Kombinationen bleibt konstant.
Um die Verwendung im Code zu vereinfachen (solange JavaScript keine switch expression unterstützt), hat sich bei mir folgende Hilfsfunktion bewährt:
const match = <T,>(obj: {
initial: () => T;
loading: () => T;
error: (error: Error) => T;
success: (data: Data) => T;
}): T => {
switch (state.kind) {
case "initial": {
return obj.initial();
}
case "loading": {
return obj.loading();
}
case "error": {
return obj.error(state.error);
}
case "success": {
return obj.success(state.data);
}
default: {
const _exhaustiveCheck: never = state;
throw new Error("Unreachable code");
}
}
}
Damit wird der Code lesbarer.
const Component = () => {
const { match } = useTheState();
return match({
initial: () => <RenderInitialState />,
loading: () => <RenderLoadingState />,
error: (e) => <RenderErrorState error={e} />,
success: (d) => <RenderSuccessState data={d} />,
});
};
Eine sehr ähnliche Funktionalität (und sehr viel mehr) bietet die ts-pattern library. Damit sieht der Code so aus:
import { match } from 'ts-pattern';
const Component = () => {
const theState = useTheState();
return match(theState)
.with({ kind: 'initial' }, () => <RenderInitialState />)
.with({ kind: 'loading' }, () => <RenderLoadingState />)
.with({ kind: 'error' }, ({ error }) => <RenderErrorState error={error} />)
.with({ kind: 'success' }, ({ data }) => <RenderSuccessState data={data} />)
.exhaustive();
};
Ein weiterer hervorragender Use-Case für Union Types ist die Verwendung bei Aktionen/ Operationen als Result-Type. Aktionen können fehlschlagen und das oft aus vielen verschiedenen Gründen. Mit einem Result-Type können alle möglichen Ergebnisse transparent modelliert werden. Fehler werden so zu einem Teil der API, können einfach erweitert/ angepasst werden, können beliebige zusätzliche Informationen enthalten und sie können exhaustive abgearbeitet werden.
Klassischerweise werden dafür Exceptions verwendet. Insbesondere in Sprachen, die keine Union Types unterstützen. Exceptions haben allerdings erhebliche Nachteile: In vielen Sprachen sind sie nicht Teil der Methoden-Signatur, und selbst in Sprachen wie Java mit checked Exceptions bleiben viele Fehlerarten unsichtbar. Der Aufrufer muss wissen, welche Exceptions geworfen werden können und diese entsprechend behandeln. Das ist insbesondere problematisch, wenn die Methode aus einem Interface stammt, oder die Methode selbst viele andere Methoden aufruft, die wiederum Exceptions werfen können. Zudem sind sie nicht exhaustive. Werden neue Exceptions hinzugefügt, gibt es keinen Hinweis vom Compiler, der sicherstellt, dass alle Fälle behandelt werden.
Dazu kommt, dass viele Entwickler entweder zu faul sind, oder es schlicht zu umständlich ist, für jeden Fehlerfall eine neue Exception zu definieren. Das Ergebnis sind oft generische Exceptions, die wenig bis keine Informationen über den Fehler enthalten und kompliziert behandelt werden müssen. Mit Union und Result-Types lösen sich all diese Probleme elegant auf: Fehlerhandling wird explizit, typsicher und exhaustive.
import { match } from 'ts-pattern';
type SomeActionResult =
| { kind: 'success'; data: Data }
| { kind: 'reason-a'; additionalData: string }
| { kind: 'reason-b'; additionalData: number }
| { kind: 'reason-c'; error: Error };
// ...
const someAction = (): SomeActionResult => {
// ...
}
const result = someAction();
match(result)
.with({ kind: 'success' }, ({ data }) => {
// handle success
})
.with({ kind: 'reason-a' }, ({ additionalData }) => {
// handle reason-a
})
.with({ kind: 'reason-b' }, ({ additionalData }) => {
// handle reason-b
})
.with({ kind: 'reason-c' }, ({ error }) => {
// handle reason-c
})
.exhaustive();
Union Types sind ein mächtiges Werkzeug, um unmögliche Zustände mit TypeScript zu vermeiden. Sie ermöglichen es, Datenstrukturen so zu gestalten, dass sie dem Use-Case entsprechen, diesen transparent darstellen und gleichzeitig Fehlerquellen minimieren. Mit exhaustiveness checks wird sichergestellt, dass alle möglichen Zustände berücksichtigt werden, und bei Änderungen an der Datenstruktur alle betroffenen Stellen im Code angepasst werden müssen.
Ich versichere dir: Sobald du anfängst mit Union-Types zu arbeiten, wirst du dich fragen, wie du jemals ohne sie ausgekommen bist.