Featured image

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"

Playground

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

Playground

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

Playground

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

Playground

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;
}
*/

Playground