Un pattern per un safe parsing di dati esterni

L'uso del tipo "unknown" e delle user-defined type guards in TypeScript.

Ipotizziamo di trovarci nella seguente situazione. Ci hanno commissionato un progetto front-end per la gestione delle risorse umane di un'azienda. Purtroppo non abbiamo il controllo sul back-end, che tra l'altro inizia ad avere una certa età, perciò non abbiamo la certezza di ricevere sempre dei dati corretti. Oltre a ciò, non ci è data la possibilità di filtrare i dati ricevuti: quando richiediamo gli utenti li otteniamo tutti, indipendentemente dal loro ruolo.

TypeScript ci aiuta a modellare situazioni come questa grazie al tipo unknown e alle user-defined type guards. Vediamo come!  

# Che cos'è un tipo

Innanzitutto chiediamoci: cosa è un tipo? Da un punto di vista matematico possiamo equipararlo ad un insieme che comprende delle entità, entità che avranno quel tipo, sulle quali sono definite alcune operazioni che prendono forma negli operatori messi a disposizione dal lunguaggio e nelle funzioni che scriviamo.
Ad esempio, nel seguente codice definiamo una variabile di tipo number:

let n : number = 42

Ciò significa che n potrà assumere solo quei valori che stanno nell'insieme number. Inoltre potremo usare con n solo gli operatori che agiscono sui numeri oltre alle funzioni che hanno come parametro un number.  

# I tipi in gioco

Per semplicità, immaginiamo che possano esistere solo due tipi di utenti: dipendente e amministratore.

type Employee = { name: string, email: string, jobs: Array<string>, _type = "employee" }
type Admin = { name: string, email: string, projects: Array<string>, _type = "admin" }

type User = Employee | Admin

Tramite il tag _type creiamo quella che in gergo tecnico viene definita discriminated union. Tra le altre cose, il tag ci permette di separare completamente, al livello del type system, l'insieme degli Employee da quello degli Admin, oltre a rendere possibili delle chicche come l'exhaustive type checking. Il back-end non fornirà in automatico il tag: saremo noi a doverci preoccupare di inserirlo dopo aver determinato il tipo di ogni dato utente ricevuto.  

# Parsing

A differenza della mera validazione, il parsing mantiene una certa informazione al livello del type system oltre a validare i dati. Un modo per eseguire questa operazione consiste nell'uso delle user-defined type guards. Queste funzioni particolari ci permettono appunto di determinare se una certa entità è di un determinato tipo.

Per determinare se una certa entità sconosciuta è di tipo Employee, il pattern consigliato è il seguente:

// impostiamo come tipo dell'entità in ingresso il tipo unknown
function isEmployee(e: unknown): e is Employee {

  // se l'entità non è un oggetto, sicuramente non è un Employee
  if(e === null || typeof e !== "object") {
      return false
  }

  // se l'entità è un oggetto, possiamo permetterci di eseguire il seguente
  // cast temporaneo
  const employee = <Employee>e;

  // ovviamente non è detto che employee sia veramente di tipo Employee
  // ma il cast ci permette di accedere alle propietà che un Employee
  // dovrebbe avere senza ottenere errori da TS

  // seguono gli altri controlli
  if(typeof employee.name !== "string") return false
  if(typeof employee.email !== "string") return false
  if(!Array.isArray(employee.jobs) || !employee.jobs.every(j => typeof j === "string"))
    return false

  // se ogni controllo è soddisfatto, aggiungiamo il tag e restituiamo true
  employee._type = "employee"
  return true
}

Facciamo ora la stessa cosa per Admin:

function isAdmin(e: unknown): e is Admin {

  if(e === null || typeof e !== "object") {
      return false
  }

  const admin = <Admin>e;

  if(typeof admin.name !== "string") return false
  if(typeof admin.email !== "string") return false
  if(!Array.isArray(admin.projects) || !admin.projects.every(p => typeof p === "string"))
    return false

  Admin._type = "admin"
  return true
}

# Utilizzo

Ed ecco come le precedenti type-guards possono esserci utili:

async fetchUsers() {

    const users: Array<unknown> = await API_GET("users");

    const admins: Array<Admin> = [];
    const employees: Array<Employee> = [];

    for(const user of users) {
        if(isAdmin(user)) {
            // TS è in grado di comprendere che user è di tipo Admin
            // possiamo quindi eseguire il push nell'array di Admin
            admins.push(user)
        } else if(isEmployee(user)) {
            // TS è in grado di comprendere che user è di tipo Employee
            // possiamo quindi eseguire il push nell'array di Employee
            employees.push(user)
        } else {
            // gestiamo il caso di un utente errato come preferiamo
        }
    }

    return { admins, employees }
}