Pompei Tech
Guide · typescript

Union e intersection types

Union e intersection types

Immagina il buttafuori all'ingresso di un locale. Per farti entrare, ti chiede un documento: va bene la carta d'identità, oppure il passaporto, oppure la patente — uno qualsiasi dei tre basta. Questa è logica "OR": ti serve uno tra i documenti validi. Ora immagina di voler entrare nell'area VIP dello stesso locale: lì non basta un documento qualsiasi, ti serve la tessera socio e, allo stesso tempo, avere più di 18 anni. Questa è logica "AND": ti servono entrambi i requisiti contemporaneamente.

Union e intersection types in TypeScript funzionano esattamente così: sono gli operatori logici booleani (AND, OR) applicati ai tipi, invece che ai valori booleani.

Union e intersection, concettualmente

Prendiamo due insiemi di esempio da usare in questa discussione:

text
Pari = { 2, 4, 6, 8 }
UnoACinque = { 1, 2, 3, 4, 5 }

Union types |

Un union type può essere pensato come un OR, per i tipi, e TypeScript usa il simbolo pipe (|) per rappresentare l'operatore di union type.

Usando l'esempio sopra, se volessimo calcolare UnoACinque | Pari, combineremmo tutti i membri dell'insieme UnoACinque con tutti i membri dell'insieme Pari:

text
UnoACinque | Pari => { 1, 2, 3, 4, 5, 6, 8 }

Se pensi alle assunzioni che potremmo fare su un membro scelto a caso da questo insieme, non potremmo essere sicuri che sia compreso tra 1 e 5, e non potremmo essere sicuri che sia dispari.

Intersection types &

Un intersection type può essere pensato come un AND, per i tipi, e TypeScript usa il simbolo & (e commerciale) per rappresentare l'operatore di intersection type.

Riprendendo l'esempio, se volessimo calcolare UnoACinque & Pari, troveremmo tutti i membri che gli insiemi UnoACinque e Pari hanno in comune:

text
UnoACinque & Pari => { 2, 4 }

Union types in TypeScript

Ripensiamo al concetto di tipo letterale visto in un esempio precedente:

ts
const 
const umidita: 79
umidita
= 79;

Se volessimo creare un union type che rappresenti l'insieme { 1, 2, 3, 4, 5 }, potremmo farlo usando l'operatore |. Possiamo anche usare la keyword type per dare un nome a questo tipo (ne parleremo meglio nel prossimo capitolo):

ts
type type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque = 1 | 2 | 3 | 4 | 5;

let let numeroBasso: UnoACinquenumeroBasso: type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque = 3;
numeroBasso = 8;
Type '8' is not assignable to type 'UnoACinque'.

e potremmo creare un altro tipo chiamato Pari per rappresentare l'insieme { 2, 4, 6, 8 }:

ts
type type Pari = 2 | 4 | 6 | 8Pari = 2 | 4 | 6 | 8;

let let numeroPari: ParinumeroPari: type Pari = 2 | 4 | 6 | 8Pari = 2;
numeroPari = 5;
Type '5' is not assignable to type 'Pari'.

Creare esplicitamente lo union type ora è semplice:

ts
type type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque = 1 | 2 | 3 | 4 | 5;
type type Pari = 2 | 4 | 6 | 8Pari = 2 | 4 | 6 | 8;

let 
let pariOBasso: 1 | 2 | 3 | 4 | 5 | 6 | 8
pariOBasso
= 5 as type Pari = 2 | 4 | 6 | 8Pari | type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque;

I union type spuntano spesso dove il flusso di controllo può produrre un valore diverso per percorsi di codice diversi.

Per esempio, la funzione lanciaMoneta() restituirà "testa" se un numero casuale tra 0 e 1 è >= 0.5, oppure "croce" altrimenti.

ts
function function lanciaMoneta(): "testa" | "croce"lanciaMoneta() {
  if (var Math: Math
An intrinsic object that provides basic mathematics functionality and constants.
Math
.Math.random(): number
Returns a pseudorandom number between 0 and 1.
random
() > 0.5) return "testa"; // il ramo "testa"
return "croce"; // il ramo "croce" } const
const esito: "testa" | "croce"
esito
= function lanciaMoneta(): "testa" | "croce"lanciaMoneta();

Rendiamo le cose un po' più interessanti usando delle tuple, strutturate così:

  • [0] è "successo" oppure "errore".
  • [1] è qualcosa di diverso, a seconda del valore trovato in [0]:
    • caso "successo": un'informazione di contatto: { nome: string; email: string; }.
    • caso "errore": un'istanza di Error.
ts
const 
const successo: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}]
successo
= ["successo", { nome: "Davide"nome: "Davide", email: "davide@example.com"email: "davide@example.com" }] as
type const = readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}]
const
;
const
const errore: readonly ["errore", Error]
errore
= ["errore", new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Qualcosa è andato storto!")] as type const = readonly ["errore", Error]const;

Decideremo quale delle due cose succede realmente in base al nostro lancio di moneta 50/50 di prima:

ts
function 
function forseRecuperaDatiUtente(): readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
forseRecuperaDatiUtente
() {
if (function lanciaMoneta(): "testa" | "croce"lanciaMoneta() === "testa") { return
const successo: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}]
successo
;
} else { return const errore: readonly ["errore", Error]errore; } } const
const esito2: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
esito2
=
function forseRecuperaDatiUtente(): readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
forseRecuperaDatiUtente
();

Questo tipo è decisamente più interessante.

Lavorare con union types

Proseguiamo con l'esempio sopra e proviamo a fare qualcosa con il valore "esito".

Prima destrutturiamo la tupla e vediamo cosa dice TypeScript sui suoi membri:

ts
const 
const esito2: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
esito2
=
function forseRecuperaDatiUtente(): readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
forseRecuperaDatiUtente
();
const [const primo: "successo" | "errore"primo,
const secondo: {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
} | Error
secondo
] =
const esito2: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
esito2
;
const primo: "successo" | "errore"
primo
;
const secondo: {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
} | Error
secondo
;

:::tip Un buon momento per curiosare Apri il TypeScript Playground ed esplora primo e secondo scrivendo primo. e secondo. per vedere cosa propone l'autocompletamento per ciascuno dei due. :::

Possiamo vedere che l'autocompletamento per il primo valore suggerisce che è una stringa. Questo perché, indipendentemente dal fatto che sia effettivamente la stringa "successo" o "errore", è comunque sicuramente una stringa.

Il secondo valore è un po' più complesso — solo la proprietà name (in inglese, perché è ereditata sia dagli oggetti "utente" personalizzati che dalle istanze di Error) sarebbe disponibile per l'autocompletamento, dato che sia il nostro oggetto "informazioni utente" (nome, in realtà, ma nell'esempio con Error la proprietà comune sarebbe name) sia le istanze della classe Error condividono soltanto le proprietà che hanno davvero in comune.

Guardiamo di nuovo il nostro esempio precedente con {1,2,3,4,5} | {2,4,6,8} e consideriamo come questi operatori AND e OR sui tipi descrivano l'insieme dei valori possibili, e le assunzioni che possiamo fare su un valore qualsiasi dell'insieme:

ts
type type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque = 1 | 2 | 3 | 4 | 5;
type type Pari = 2 | 4 | 6 | 8Pari = 2 | 4 | 6 | 8;

function function stampaPari(pari: Pari): voidstampaPari(pari: Paripari: type Pari = 2 | 4 | 6 | 8Pari): void {}
function function stampaBasso(basso: UnoACinque): voidstampaBasso(basso: UnoACinquebasso: type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque): void {}
function function stampaPariBasso(num: 2 | 4): voidstampaPariBasso(num: 2 | 4num: 2 | 4): void {}
function function stampaNumero(num: number): voidstampaNumero(num: numbernum: number): void {}

let let x: 1 | 2 | 3 | 4 | 5 | 6 | 8x = 5 as type Pari = 2 | 4 | 6 | 8Pari | type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque;

// Cosa accetta come valore Pari | UnoACinque?
let let pariOBasso2: 1 | 2 | 3 | 4 | 5 | 6 | 8pariOBasso2: type Pari = 2 | 4 | 6 | 8Pari | type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque;
let pariOBasso2: 1 | 2 | 3 | 4 | 5 | 6 | 8pariOBasso2 = 6; // un numero pari
let pariOBasso2: 1 | 2 | 3 | 4 | 5 | 6 | 8pariOBasso2 = 3; // un numero basso
let pariOBasso2: 1 | 2 | 3 | 4 | 5 | 6 | 8pariOBasso2 = 4; // un numero pari e basso

// Quali requisiti soddisfa Pari | UnoACinque?
function stampaPari(pari: Pari): voidstampaPari(x); // x potrebbe non essere pari
Argument of type '1 | 2 | 3 | 4 | 5 | 6 | 8' is not assignable to parameter of type 'Pari'. Type '1' is not assignable to type 'Pari'.
function stampaBasso(basso: UnoACinque): voidstampaBasso(x); // x potrebbe non essere basso
Argument of type '1 | 2 | 3 | 4 | 5 | 6 | 8' is not assignable to parameter of type 'UnoACinque'. Type '6' is not assignable to type 'UnoACinque'.
function stampaPariBasso(num: 2 | 4): voidstampaPariBasso(x); // x potrebbe non essere né 2 né 4
Argument of type '1 | 2 | 3 | 4 | 5 | 6 | 8' is not assignable to parameter of type '2 | 4'. Type '1' is not assignable to type '2 | 4'.
function stampaNumero(num: number): voidstampaNumero(let x: 1 | 2 | 3 | 4 | 5 | 6 | 8x); // x è sicuramente un number: nessun errore qui

C'è un'asimmetria interessante qui. Un Pari | UnoACinque può accettare una gamma piuttosto ampia di valori, ma proprio a causa di questa flessibilità, non soddisfa i requisiti di type checking della maggior parte delle funzioni stampa*.

In sostanza, | significa "qualsiasi cosa in uno dei due insiemi" in termini di valori ammessi, e per questo motivo è disponibile solo il comportamento sicuramente presente su ogni membro di entrambi gli insiemi.

Restringere con i type guard

In definitiva, dobbiamo "separare" le due possibilità del nostro valore, altrimenti non riusciremo ad andare molto lontano. Possiamo farlo con i type guard.

I type guard sono espressioni che, quando usate con un'istruzione di controllo del flusso, ci permettono di ottenere un tipo più specifico per un determinato valore.

Mi piace pensarli come una specie di "colla" tra il type checking a compile time e l'esecuzione a runtime del tuo codice. Cominciamo con uno che dovresti già conoscere: instanceof.

ts
const 
const esito: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
esito
=
function forseRecuperaDatiUtente(): readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
forseRecuperaDatiUtente
();
const [const primo: "successo" | "errore"primo,
const secondo: {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
} | Error
secondo
] =
const esito: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
esito
;
if (
const secondo: {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
} | Error
secondo
instanceof var Error: ErrorConstructorError) {
// in questo ramo, "secondo" è un Error
const secondo: Error
secondo
;
} else { // in questo ramo, "secondo" è l'oggetto con le info utente
const secondo: {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}
secondo
;
}

TypeScript ha una comprensione speciale di cosa significa quando il nostro controllo instanceof restituisce true o false, e crea un ramo di codice che gestisce ciascuna possibilità.

E va anche meglio...

Union discriminate

ts
const 
const esito: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
esito
=
function forseRecuperaDatiUtente(): readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
forseRecuperaDatiUtente
();
const [const primo: "successo" | "errore"primo,
const secondo: {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
} | Error
secondo
] =
const esito: readonly ["successo", {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}] | readonly ["errore", Error]
esito
;
if (const primo: "successo" | "errore"primo === "errore") { // in questo ramo, "secondo" è un Error
const secondo: Error
secondo
;
} else { // in questo ramo, "secondo" è l'oggetto con le info utente
const secondo: {
    readonly nome: "Davide";
    readonly email: "davide@example.com";
}
secondo
;
}

TypeScript capisce che la prima e la seconda posizione della nostra tupla sono collegate tra loro. Quello che stiamo vedendo qui è quello che a volte viene chiamato un tipo union discriminata o "taggata" — un pattern che userai moltissimo scrivendo TypeScript in produzione, perché è il modo più sicuro e leggibile di modellare "questa cosa può essere di forme diverse, e questo campo mi dice quale".

Intersection types in TypeScript

Gli intersection type in TypeScript si possono descrivere usando l'operatore & (e commerciale).

Guardiamo di nuovo il nostro esempio con gli insiemi di numeri:

ts
type type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque = 1 | 2 | 3 | 4 | 5;
type type Pari = 2 | 4 | 6 | 8Pari = 2 | 4 | 6 | 8;

function function stampaPari(pari: Pari): voidstampaPari(pari: Paripari: type Pari = 2 | 4 | 6 | 8Pari): void {}
function function stampaBasso(basso: UnoACinque): voidstampaBasso(basso: UnoACinquebasso: type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque): void {}
function function stampaPariBasso(num: 2 | 4): voidstampaPariBasso(num: 2 | 4num: 2 | 4): void {}
function function stampaNumero(num: number): voidstampaNumero(num: numbernum: number): void {}

let let y: 2 | 4y = 4 as type Pari = 2 | 4 | 6 | 8Pari & type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque;

// Cosa accetta come valore Pari & UnoACinque?
let let pariEBasso: 2 | 4pariEBasso: type Pari = 2 | 4 | 6 | 8Pari & type UnoACinque = 1 | 2 | 3 | 4 | 5UnoACinque;
pariEBasso = 6; // 6 non è basso (tra 1 e 5)
Type '6' is not assignable to type '2 | 4'.
pariEBasso = 3; // 3 non è pari
Type '3' is not assignable to type '2 | 4'.
let pariEBasso: 2 | 4pariEBasso = 4; // 4 è sia pari che basso: OK // Quali requisiti soddisfa Pari & UnoACinque? Tutti: nessun errore qui sotto function stampaPari(pari: Pari): voidstampaPari(let y: 2 | 4y); function stampaBasso(basso: UnoACinque): voidstampaBasso(let y: 2 | 4y); function stampaPariBasso(num: 2 | 4): voidstampaPariBasso(let y: 2 | 4y); function stampaNumero(num: number): voidstampaNumero(let y: 2 | 4y);

Anche qui vediamo un certo grado di asimmetria, ma quasi nella direzione opposta rispetto ai union type. L'intersection type può accettare una gamma di valori molto ristretta, ma proprio per questo può soddisfare i requisiti di type checking di tutte e quattro le funzioni stampa*.

In sostanza, & significa "qualsiasi cosa che sia in entrambi gli insiemi" in termini di valori ammessi, e per questo motivo possiamo usare qualsiasi comportamento sicuramente presente sui membri di uno qualsiasi dei due insiemi.

È di gran lunga meno comune usare gli intersection type rispetto agli union type — nella pratica mi aspetto che per te sia un rapporto di almeno 50 a 1. Un caso d'uso reale dove troverai (e apprezzerai) un intersection type è Object.assign(a, b), che unisce le proprietà di due oggetti in uno nuovo il cui tipo è, appunto, l'intersezione dei due tipi di partenza.