Featured image

 

TypeScript ci permette di etichettare i vari elementi di una union per ottenere una tagged union, o discriminated union. Ad esempio, possiamo differenziare più forme assegnando ad ognuna un type literal dedicato.

type Shape = 
  | {
      _tag: "circle";
      radius: number;
    } 
  | {
      _tag: "square";
      side: number;
    }
  | {
      _tag: "rectangle";
      base: number;
      height: number;
    };

In gergo tecnico un tipo come Shape si chiama sum type. Questo perché l’insieme dei valori avente tale tipo è esattamente l’unione dei valori dei suoi costituenti e questi ultimi non hanno elementi in comune: sono disgiunti. Il discriminante, in questo esempio, è il field _tag, che deve essere unico per ogni componente di Shape.

Questo pattern ha innumerevoli vantaggi, tra i quali la possibilità di sfruttare le piene capacità di code flow analysis e type narrowing del linguaggio.

function getArea(shape: Shape): number {
  switch(shape._tag) {
    case "circle": {
      return Math.PI * shape.radius ** 2;
    }
    case "square": {
      return shape.side ** 2;
    }
    case "rectangle": {
      return shape.base * shape.height;
    }
  }
}

A seconda di quello che sarà il valore effettivo del field _tag, TypeScript è in grado di discriminare e rifinire il tipo di dell’argomento shape da Shape a uno dei tre suoi costituenti, permettendo quindi un accesso sicuro alle corrispondenti proprietà.

Ipotizziamo ora di voler quadrare un cerchio. Ovvero, vogliamo creare una funzione che prenda in ingresso esclusivamente un "circle" e restituisca un "square". A disposizione abbiamo però solo il tipo Shape, non i suoi costituenti. Ci viene quindi in aiuto la type function Extract, disponibile nella stdlib dalla versione 2.8 di TypeScript.

// { _tag: "circle"; radius: number; }
type Circle = Extract<Shape, { _tag: "circle" }>;

// { _tag: "square"; side: number; }
type Square = Extract<Shape, { _tag: "square" }>;

function squareCircle(circle: Circle): Square {
  return {
    _tag: "square",
    side: Math.sqrt(getArea(circle)),
  }
}

Playground

Come funziona? La definizione di Extract è la seguente:

type Extract<T, U> = T extends U ? T : never;

Essa prende in ingresso due type parameter, T e U, e utilizza un conditional type per verificare se T è assegnabile ad U. In caso positivo restituisce T, altrimenti restituisce never, il bottom type del linguaggio.

Vediamo qualche esempio semplice per capirne meglio il funzionamento.

// 10, perché 10 è assegnabile a number
type T0 = Extract<10, number>;

// true, perché true è assegnabile a boolean
type T1 = Extract<true, boolean>; 

// never, perché true non è assegnabile a false
type T2 = Extract<true, false>; 

Nel caso di tipi oggetto dobbiamo ricordare che TypeScript permette di assegnare un’entità di tipo A dove ne è richiesta una di tipo B se A ha almeno, e non esattamente, tutte le proprietà possedute da B, ovviamente del giusto tipo. Invece, il contrario non è ammesso.

// { n: number, s: string }, poiché esso ha almeno tutte le proprietà di { n: number }
type T3 = Extract<{ n: number, s: string }, { n: number }>;

// never
type T4 = Extract<{ n: number }, { n: number, s: string }>;

Ora veniamo al concetto più delicato: nel caso in cui T fosse una union entrerebbe in gioco la distribuzione rispetto all’unione del conditional type.

Extract<A | B, U>

// diventa

Extract<A, U> | Extract<A, B>

Questo perché nella definizione del conditional type interno a Extract si ha che il type parameter T è naked. La documentazione non fornisce una definizione precisa di questo attributo, ma in poche parole ogni volta che ci troviamo di fronte al pattern T extends U ? X : Y con T una type variable si ha in aggiunta che T è considerata naked. E se T è naked, allora il conditional si distribuisce rispetto all’unione.

(A | B) extends U ? X : Y

// diventa

(A extends U ? X : Y) | (B extends U ? X : Y)

Vediamo quindi qualche esempio.

// false
type T5 = Extract<boolean, false>;

// { _tag: "circle"; radius: number; }
type T6 =  Extract<Shape, { _tag: "circle" }>;

T5 risulta false poiché boolean viene espanso in true | false e per la distribuzione rispetto all’unione Extract<true | false, false> è pari a Extract<true, false> | Extract<false, false> ovvero never | false ovvero false, in quanto never è l’elemento neutro dell’unione (never corrisponde sostanzialmente all’insieme vuoto).

T6 risulta { _tag: "circle"; radius: number; } poiché Shape viene espanso nei tre costituenti ed Extract si distribuisce rispetto a tale unione, ma solo uno dei tre elementi, ovvero { _tag: "circle"; radius: number; }, è assegnabile al tipo { _tag: "circle" } per quanto detto prima sulle regole di assegnabilità.

Extract<Shape, { _tag: "circle" }>

// diventa

Extract<
  | { _tag: "circle"; radius: number; }
  | { _tag: "square"; side: number; }
  | { _tag: "rectangle"; base: number; height: number; },
  { _tag: "circle" }
>

// diventa

| Extract<{ _tag: "circle"; radius: number; }, { _tag: "circle" }>
| Extract<{ _tag: "square"; side: number; }, { _tag: "circle" }>
| Extract<{ _tag: "rectangle"; base: number; height: number; }, { _tag: "circle" }>

// diventa

| { _tag: "circle"; radius: number; }
| never // _tag: "square" non assegnabile a _tag: "circle"
| never // _tag: "rectangle" non assegnabile a _tag: "circle"

// diventa

{ _tag: "circle"; radius: number; }