Menu

Unie dyskryminowane w TypeScript: tagged unions wyjaśnione

Unia dyskryminowana to unia typów obiektowych, które mają wspólną właściwość-znacznik z typem literałowym, na przykład kind albo status. Sprawdzenie znacznika zawęża cały obiekt. Poznaj ten wzorzec, zawężanie w switch, wyczerpujące sprawdzenia z never i modelowanie wyników API, stanu żądań i maszyn stanów.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Unia dyskryminowana (nazywana też tagged union) to unia typów obiektowych, które mają jedną wspólną właściwość, znacznik, z inną wartością literałową w każdym członie. Sprawdzenie znacznika mówi TypeScriptowi, z którym członem masz do czynienia, a pozostałe właściwości tego członu stają się dostępne.

Wewnątrz case "circle" odczyt shape.width byłby błędem kompilacji, bo człon koła nie ma width. Znacznik to zwykłe dane: w czasie działania to po prostu właściwość z napisem, a switch to zwykły JavaScript.

Co sprawia, że unia jest dyskryminowana

Zawężanie po znaczniku działa, gdy spełnione są trzy warunki:

  1. Każdy człon jest typem obiektowym.
  2. Każdy człon ma tę samą nazwę właściwości (dyskryminator). Nazwę wybierasz sam: popularne są kind, type, status i tag.
  3. W każdym członie ta właściwość ma typ literałowy: literał napisowy, liczbowy albo logiczny, lub null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

Jeśli jeden człon zadeklaruje znacznik jako zwykły string zamiast literału, porównanie znacznika przestaje zawężać unię, a właściwości konkretnych członów pozostają niedostępne (TS2339).

Zawężanie po znaczniku

Każde sprawdzenie znacznika zrozumiałe dla TypeScriptu zawęża obiekt: switch, if/else, === i !==, a nawet znacznik wyciągnięty przez destrukturyzację, o ile jest const:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

Sprawdzenie właściwości przez "radius" in shape też zawęża, ale porównanie znacznika jest czytelniejsze i umożliwia sprawdzanie wyczerpujące.

Sprawdzenia wyczerpujące

Największą korzyść z tego wzorca widać, gdy unia rośnie. Przy jawnym typie zwracanym i bez default TypeScript wie, że switch musi obsłużyć każdy znacznik, więc nowy człon bez swojego case to błąd kompilacji:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Ten komunikat nie mówi, którego przypadku brakuje. Pomocnik assertNever to robi, a do tego rzuca wyjątek w czasie działania, gdy dane z zewnątrz (JSON, starszy klient) niosą znacznik, który według typów nie może istnieć:

Dodaj piąty status bez case, a linia z assertNever(state) zgłosi Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', podając nazwę członu. Więcej o tym na stronie o never.

Niemożliwe stany naprawdę niemożliwe

Powyższy RequestState zastępuje popularny, słabszy kształt:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

Przy opcjonalnych polach „ładowanie z danymi i z błędem” przechodzi sprawdzanie typów, a każdy czytelnik musi zgadywać, które kombinacje naprawdę mogą wystąpić. W unii dyskryminowanej data istnieje tylko w stanie success, a error tylko w error, więc kod, który odczytuje state.data, musi najpierw udowodnić, że jest w stanie success. Sam typ dokumentuje poprawne stany.

Modelowanie wyników: sukces albo porażka

Znacznik logiczny wystarczy dla „zadziałało albo nie”. Ten typ Result to popularna alternatywa dla rzucania wyjątków przy spodziewanych porażkach, takich jak nieprawidłowe dane wejściowe:

Wywołujący nie może odczytać result.value bez wcześniejszego sprawdzenia result.ok, a właśnie o tym sprawdzeniu łatwo zapomnieć przy wyjątkach albo zwracaniu null.

Maszyny stanów i reducery

Dwie unie dyskryminowane, jedna dla stanów i jedna dla akcji, opisują maszynę stanów. Reducer wykonuje switch po akcji i zwraca następny stan; kompilator sprawdza, czy każdy zwracany obiekt jest poprawnym stanem:

{ ...state, name: "paused" } kompiluje się tylko dlatego, że state zostało wcześniej zawężone do członu odtwarzania, więc kopia zawiera track i position. Zwrócenie samego { name: "paused" } byłoby błędem: stan wstrzymania wymaga utworu. To ten sam kształt, którego używają reducery Reduxa i useReducer w React.

Pułapka poszerzania

Znacznik musi pozostać typem literałowym. Obiekt zbudowany w zmiennej bez adnotacji ma znacznik poszerzony do string i nie pasuje wtedy do żadnego członu:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

Regułę, która za tym stoi, czyli poszerzanie zmiennych właściwości, wyjaśnia strona o typach literałowych.

Najczęściej zadawane pytania

Czym jest unia dyskryminowana w TypeScript?

To unia typów obiektowych, w której każdy człon ma tę samą właściwość (dyskryminator albo znacznik) z innym typem literałowym, na przykład { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Sprawdzenie shape.kind === "circle" zawęża shape do członu koła, więc jego pozostałe właściwości stają się dostępne.

Czym różni się unia od unii dyskryminowanej?

Unia dyskryminowana to unia z jedną dodatkową regułą: wszystkie człony mają wspólną właściwość z unikalnym typem literałowym. Zwykłą unię, taką jak Cat | Fish, trzeba zawężać przez in albo własnych strażników typu; unię dyskryminowaną zawęża porównanie jednej właściwości, a switch po tej właściwości można sprawdzić pod kątem wyczerpania wszystkich przypadków.

Jak sprawić, żeby switch po unii dyskryminowanej był wyczerpujący?

Albo nadaj funkcji jawny typ zwracany i pomiń default (brakujący przypadek to wtedy błąd TS2366), albo dodaj default: return assertNever(value) z function assertNever(x: never): never { throw ... }. Druga forma podaje w błędzie nazwę nieobsłużonego członu i dodatkowo rzuca wyjątek w czasie działania, gdy przyjdą nieoczekiwane dane.

Dlaczego moja unia dyskryminowana się nie zawęża?

Znacznik musi mieć typ literałowy w każdym członie. Jeśli obiekt został utworzony bez adnotacji, jego kind zostaje poszerzone do string i obiekt nie pasuje już do żadnego członu (TS2345/TS2322). Napraw to adnotacją, as const albo tworząc obiekt tam, gdzie oczekiwany jest typ unii. Człon z typem kind: string też uniemożliwia zawężanie unii po kind.

Czy dyskryminator może być wartością logiczną albo liczbą?

Tak. Działa każdy typ literałowy: napisy (kind: "circle"), liczby (version: 2), wartości logiczne (ok: true / ok: false), a nawet null czy undefined. Znaczniki napisowe są najczęstsze, bo są czytelne w logach i w JSON.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ