Featured image

Series: TypeScript

Introduzione Link to heading

La capacità di TypeScript di rifinire un tipo analizzando il control flow del codice è una delle feature più comode e utilizzate del linguaggio. Vediamo un semplice esempio per chiarire di che cosa si tratta:

function foo(x: string | number) {
  if(typeof x === "string") {
    console.log(x.repeat(x.length));
  } else {
    console.log(Math.sqrt(x));
  }
}

TypeScript è in grado di comprendere che se la condizione del costrutto if è verificata il tipo di x può essere correttamente “ristretto” al solo string: ecco che diventa possibile accedere al metodo repeat e alla proprietà length della stringa. Ciò ha come immediata conseguenza il fatto che nel ramo else la variabile x può solamente essere un number, quindi è possibile darlo in input a una delle utils esposte da Math.

Il problema Link to heading

TypeScript non supporta il narrowing di un type parameter T in base, ad esempio, al valore contenuto in una variabile avente quel tipo. In altre parole l’analisi del control flow va quasi completamente a farsi benedire. Non dal papa, da Hejlsberg in persona. Questo perché è facilissimo ricadere in una situazione nella quale tale narrowing sarebbe scorretto, o unsound, come dicono gli inglesi.

Ecco che incontriamo dei problemi in situazioni come la seguente:

interface Payloads {
  auth: {
    username: string;
    password: string;
  };
  cart: {
    items: { id: string; quantity: number }[];
    price: number;
    appliedCoupon?: string;
  };
  room: {
    id: string;
    name: string;
    partecipants: { username: string }[];
  };
}

function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
  switch (service) {
    case "auth":
      return { 
        username: "johndoe",
        password: "eodnhoj",
      };
    case "cart":
      return { 
        items: [],
        price: 0,
      };
    case "room":
      return {
        id: "123",
        name: "kitchen",
        partecipants: [{ username: "johndoe" }],
      };
  }
}

Playground

La funzione createPayload evidentemente copre ogni caso restituendo di volta in volta il payload corretto, eppure TypeScript (v. 4.6.2) non è d’accordo e ci segnala due diverse tipologie di errore.

La prima riguarda il tipo di ritorno della funzione: “Function lacks ending return statement and return type does not include ‘undefined’.”. In altre parole TypeScript non considera esaustivo lo switch/case e si aspetta quindi che venga gestito nel codice anche il default case, sebbene non potrà mai accadere che ll parametro service risulti diverso dai valori "auth", "cart" e "room".
Possiamo facilmente risolvere l’errore nel seguente modo:

function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
  switch (service) {
    case "auth":
      return { 
        username: "johndoe",
        password: "eodnhoj",
      };
    case "cart":
      return { 
        items: [],
        price: 0,
      };
    case "room":
      return {
        id: "123",
        name: "kitchen",
        partecipants: [{ username: "johndoe" }],
      };
    default:
      throw new Error("undefined service");
  }
}

Playground

Il secondo tipo di errore invece risulta molto più prolisso e oscuro, e intacca ogni ramo dello switch. TypeScript ci dice che ogni valore restituito “is not assignable to type ‘Payloads[K]'”, in particolare ci dice che non è assegnabile al tipo:

{
  username: string;
  password: string;
} & {
  items: { id: string; quantity: number }[];
  price: number;
  appliedCoupon?: string | undefined;
} & {
  id: string;
  name: string;
  partecipants: { username: string }[];
};

Perché? Da dove saltano fuori quelle intersezioni? Cerchiamo di capire meglio cosa succede.
Abbiamo detto che TypeScript non supporta l’analisi del control flow per rifinire un tipo parametrico, e K è proprio questo! TypeScript sa che service può essere ristretta, ad esempio, al solo caso "auth', perciò ci permette di creare uno switch/case come quello presente in createPayload, ma non rifinisce di conseguenza anche il type parameter K. Perciò non sa che { username: "johndoe", password: "eodnhoj" } è assegnabile a Payloads[K] in quella specifica circostanza.
TypeScript è essenzialmente fin troppo cauto, e ci richiede di restituire un valore che per ogni possibile K è assegnabile a Payloads[K]. Tale valore dovrà quindi contenere ogni possibile proprietà disponibile accedendo ad una qualsiasi chiave di un oggetto Payloads, perciò il suo tipo non può che essere l’intersezione tra tutti i vari tipi delle chiavi presenti in Payloads. Nessun ramo dello switch/case restituisce un valore di tale tipo, quindi TypeScript protesta in ognuno.

La soluzione Link to heading

Come possiamo creare un valore di tipo Payloads[K] in modo pulito? Siamo costretti a restituire sempre un mappazzone con millemila proprietà? Per fortuna no!
Ragioniamo sul significato di tale tipo: esso deriva dall’accedere ad un oggetto di tipo Payloads con una chiave generica K, chiave che deve essere assegnabile a keyof Payloads. Abbiamo già questa chiave, è proprio il parametro service! Ci manca giusto l’oggetto Payloads:

function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
  const payloads: Payloads = {
    auth: {
      username: "johndoe",
      password: "eodnhoj",
    },
    cart: {
      items: [],
      price: 0,
    },
    room: {
      id: "123",
      name: "kitchen",
      partecipants: [{ username: "johndoe" }],
    },
  };

  return payloads[service];
}

Playground

Ecco che TypeScript è pienamente soddisfatto perché stiamo restituendo un valore assegnabile per ogni possibile K a Payloads[K]. Oltre a ciò non è nemmeno più necessario gestire inutilmente un default case sporcando il nostro codice.