Christoph Haag

Christoph Haag

Christoph ist Softwareentwickler mit einer Passion für Musik

01.10.2025 | 7 min Lesezeit

Praktische Use-Cases für Union Types in React mit TypeScript

Make impossible states impossible

Praktische Use-Cases für Union Types in React mit TypeScript blog image

Motivation

Wenn du schon einmal eine Datenstruktur wie die folgende gesehen hast, dann ist dieser Blogpost für dich:

Fragwürdige Datenstruktur
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:

Ungültiger Zustand
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.

Setzen eines neuen Zustands
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'.

Verwendung in React
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

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.

Definition eines Union Types in TypeScript
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.

Discriminated/ Tagged Union
// 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.

Exhaustive Type Checking
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.

Exhaustive Check Error
Exhaustive Check Error

Anwendung

Wenden wir das Konzept der Union Types auf unser ursprüngliches Beispiel an, so ergibt sich folgende Datenstruktur:

Angepasste 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:

match-Funktion für einfachere Verwendung
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.

match-Funktion - Verwendung in React
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:

match-Funktion von ts-pattern
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();
};

Weiteres Beispiel

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.

Result-Type als Union mit Pattern Matching
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();

Fazit

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.

Weiter lesen