Series: TypeScript
Il problema Link to heading
L’operazione che vogliamo rendere possibile, a livello dei tipi, è quella di intersecare una unione di oggetti in un singolo oggetto. Ogni chiave k
presente in uno qualsiasi dei costituenti della union di partenza apparirà nel tipo prodotto come output, e il tipo del valore corrispondente alla chiave k
sarà l’unione di tutti i tipi dei valori della chiave k
ovunque essa appariva nell’input.
Un semplice esempio, con il risultato desiderato:
type union =
| { prop1: number, prop2: string }
| { prop2: boolean, prop3: string[] }
| { prop3?: [boolean] }
type result = {
prop1: number,
prop2: string | boolean,
prop3: string[] | [boolean] | undefined
}
Abbiamo che la chiave prop1
compare solo una volta nell’input e ha tipo number
, e tale e quale viene riportata nel tipo risultante. La chiave prop2
invece ha tipo string
nel primo oggetto e boolean
nel secondo, quindi risulta nella union string | boolean
. Similmente prop3
risulta nella union string[] | [boolean] | undefined
, in quanto in uno degli input compariva come propietà opzionale.
La soluzione Link to heading
AllKeys Link to heading
Il primo componente della soluzione è la type function AllKeys
così definita:
type AllKeys<T> = T extends unknown ? keyof T : never
la quale sfrutta la distribution over union propria dei conditional type per estrarre tutte le possibili chiavi da una unione di oggetti:
type union =
| { prop1: number, prop2: string }
| { prop2: boolean, prop3: string[] }
| { prop3?: [boolean] }
type unionAllKeys = AllKeys<union> // "prop1" | "prop2" | "prop3"
Lookup Link to heading
La seconda type function, Lookup
, distribuisce l’operazione di lookup con una chiave K
su un tipo T
rispetto ad una union di oggetti:
type Lookup<T, K extends PropertyKey> = T extends any ? T[K & keyof T] : never;
È interessante notare il fatto che la chiave K
non è vincolata ad essere assegnabile a keyof T
. TypeScript quindi non ci permette di indicizzare direttamente il tipo T
con K
(T[K]
) in quanto non ha nessuna garanzia che K
sia una chiave valida per T
. Prima di indicizzare T
con K
filtriamo quest’ultima intersecandola proprio con keyof T
: se K
risulta essere una delle chiavi di T
allora T[K & keyof T]
è identico a T[K]
, altrimenti è pari a T[never]
, il cui risultato è never
:
type obj = { prop1: number, prop2: string }
type test1 = Lookup<obj, "prop1"> // number
type test2 = Lookup<obj, "prop2"> // string
type test3 = Lookup<obj, "prop3"> // never
Perché abbiamo bisogno di questo trick? Se costringessimo K
ad essere assegnabile a keyof T
nella definizione del tipo Lookup
e T
risultasse essere una union di oggetti, allora K
potrebbe essere scelta solo tra le chiavi in comune:
type Lookup<T, K extends keyof T> = T extends any ? T[K] : never;
type union1 =
| { prop1: number, prop2: string }
| { prop2: boolean, prop3: string[] }
| { prop3?: [boolean] }
type union2 =
| { prop1: number, prop2: string, prop3: boolean[] }
| { prop2: boolean, prop3: string[] }
| { prop3?: [boolean] }
type test1 = Lookup<union1, never> // never
type test2 = Lookup<union2, "prop3"> // boolean[] | string[] | [boolean] | undefined
La union union1
non possiede chiavi in comune tra tutti i costituenti, quindi Lookup
sarebbe invocabile solo con never
come tipo per K
. Invece la union union2
ha solo prop3
come chiave condivisa, perciò Lookup
è invocabile solo con prop3
e con never
. La regola da ricordare è la seguente: keyof (A | B) = keyof A & keyof B
.
La definizione iniziale di Lookup
ci lascia invece un maggiore spazio di manovra:
type union =
| { prop1: number, prop2: string }
| { prop2: boolean, prop3: string[] }
| { prop3?: [boolean] }
type test1 = Lookup<union, "prop1"> // number
type test2 = Lookup<union, "prop2"> // string | boolean
type test3 = Lookup<union, "prop3"> // string[] | [boolean] | undefined
Nei risultati sono stati semplificati i never
che teoricamente dovrebbero essere presenti nelle tre unioni, in quanto nessun costituente possiede tutte e tre le chiavi e quindi almeno un lookup fallisce sempre. Ricordiamo che per ogni tipo A
è vero che A | never
è identico ad A
.
MergeAsUnion Link to heading
Ed eccoci arrivati finalmente alla type function MergeAsUnion
:
type MergeAsUnion<T> = { [K in AllKeys<T>]: Lookup<T, K> };
Esso è un mapped type che itera su tutte le chiavi dell’unione T
, condivise o meno, e per ognuna di esse effettua il lookup di K
nell’unione T
utilizzando la nostra type function Lookup
:
type union =
| { prop1: number, prop2: string }
| { prop2: boolean, prop3: string[] }
| { prop3?: [boolean] }
type result = MergeAsUnion<union>
/*
{
prop1: number;
prop2: string | boolean;
prop3: string[] | [boolean] | undefined;
}
*/