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:
- Każdy człon jest typem obiektowym.
- Każdy człon ma tę samą nazwę właściwości (dyskryminator). Nazwę wybierasz sam: popularne są
kind,type,statusitag. - 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.