Una discriminated union (detta anche tagged union) è una union di tipi oggetto che hanno tutti una proprietà in comune, il tag, con un valore letterale diverso in ogni membro. Controllare il tag dice a TypeScript quale membro hai, e le altre proprietà di quel membro diventano disponibili.
Dentro case "circle", leggere shape.width sarebbe un errore di compilazione, perché il membro cerchio non ha width. Il tag è un dato normale: a runtime è solo una proprietà stringa, e lo switch è JavaScript semplice.
Cosa rende discriminata una union
Il restringimento sul tag funziona quando valgono tre condizioni:
- Ogni membro è un tipo oggetto.
- Ogni membro ha lo stesso nome di proprietà (il discriminante). Il nome lo scegli tu:
kind,type,statusetagsono comuni. - In ogni membro, quella proprietà ha un tipo letterale: un letterale stringa, numerico o booleano, oppure
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 };
Se un membro dichiara il tag come semplice string invece che come letterale, confrontare il tag non restringe più la union, e le proprietà specifiche dei membri restano irraggiungibili (TS2339).
Restringere sul tag
Qualsiasi controllo sul tag che TypeScript capisce restringe l'oggetto: switch, if/else, === e !==, perfino un tag destrutturato purché sia un 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
}
Anche controllare una proprietà con "radius" in shape restringe, ma confrontare un tag è più chiaro da leggere e rende possibile il controllo di esaustività.
Controlli di esaustività
Il vantaggio più grande del pattern emerge quando la union cresce. Con un tipo di ritorno esplicito e nessun default, TypeScript sa che lo switch deve coprire ogni tag, quindi un nuovo membro senza il suo caso è un errore di compilazione:
index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
Quel messaggio non dice quale caso manca. L'helper assertNever sì, e lancia anche un errore a runtime se dati dall'esterno (JSON, un client più vecchio) portano un tag che secondo i tipi non può esistere:
Aggiungi un quinto stato senza il suo caso e la riga assertNever(state) segnala Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', nominando il membro. Altro su questo nella pagina su never.
Rendere impossibili gli stati impossibili
RequestState qui sopra sostituisce una forma comune e più debole:
// 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" };
Con i campi facoltativi, "in caricamento con dati e un errore" supera il controllo dei tipi, e chi legge deve indovinare quali combinazioni possono accadere davvero. Con la discriminated union, data esiste solo nello stato success ed error solo in error, quindi il codice che legge state.data deve prima dimostrare di essere nello stato success. Il tipo stesso documenta gli stati validi.
Modellare i risultati: successo o fallimento
Un tag booleano basta per "ha funzionato o no". Questo tipo Result è un'alternativa comune al lancio di eccezioni per fallimenti previsti come un input non valido:
Chi chiama non può leggere result.value senza controllare prima result.ok, che è proprio il controllo facile da dimenticare con le eccezioni o con i ritorni null.
Macchine a stati e reducer
Due discriminated union, una per gli stati e una per le azioni, descrivono una macchina a stati. Un reducer fa lo switch sull'azione e restituisce lo stato successivo; il compilatore verifica che ogni oggetto restituito sia uno stato valido:
{ ...state, name: "paused" } compila solo perché state era stato prima ristretto al membro in riproduzione, quindi la copia porta con sé track e position. Restituire solo { name: "paused" } sarebbe un errore: uno stato in pausa ha bisogno di una traccia. È la stessa forma usata dai reducer di Redux e da useReducer in React.
La trappola dell'allargamento
Il tag deve restare un tipo letterale. Un oggetto costruito in una variabile senza annotazione ha il tag allargato a string, e a quel punto non corrisponde a nessun membro:
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
La regola di fondo, cioè che le proprietà modificabili vengono allargate, è spiegata nella pagina sui tipi letterali.
Domande frequenti
Che cos'è una discriminated union in TypeScript?
Una union di tipi oggetto in cui ogni membro ha la stessa proprietà (il discriminante o tag) con un tipo letterale diverso, per esempio { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Controllare shape.kind === "circle" restringe shape al membro cerchio, e le sue altre proprietà diventano disponibili.
Che differenza c'è tra una union e una discriminated union?
Una discriminated union è una union con una regola in più: tutti i membri condividono una proprietà con un tipo letterale unico. Una union semplice come Cat | Fish va ristretta con in o con type guard personalizzate; una discriminated union si restringe confrontando una proprietà, e uno switch su quella proprietà può essere controllato per l'esaustività.
Come si rende esaustivo uno switch su una discriminated union?
O dai alla funzione un tipo di ritorno esplicito e nessun default (un caso mancante è allora l'errore TS2366), oppure aggiungi default: return assertNever(value) con function assertNever(x: never): never { throw ... }. La seconda forma nomina nell'errore il membro non gestito e lancia anche un errore a runtime se arrivano dati inattesi.
Perché la mia discriminated union non si restringe?
Il tag deve essere un tipo letterale in ogni membro. Se un oggetto è stato creato senza annotazione, il suo kind viene allargato a string e non corrisponde più a nessun membro (TS2345/TS2322). Risolvi con un'annotazione, con as const o creandolo dove è atteso il tipo union. Anche un membro tipizzato kind: string impedisce alla union di restringersi su kind.
Il discriminante può essere un booleano o un numero?
Sì. Funziona qualsiasi tipo letterale: stringhe (kind: "circle"), numeri (version: 2), booleani (ok: true / ok: false) e perfino null o undefined. I tag stringa sono i più comuni perché sono leggibili nei log o quando vengono inviati come JSON.