Tipi utility in Typescript | Aulab

GUIDE PER ASPIRANTI PROGRAMMATORI

Tipi utility in Typescript

Se volessimo ricapitolare quello che abbiamo imparato sul type system, potremmo sintetizzarlo così: TypeScript parte dai primitivi, i tipi nativamente presenti nel linguaggio JavaScript, e aggiunge un insieme di definizioni per vincolare la struttura di oggetti e array. Tali definizioni seguono una gerarchia nominale di tipi veri e propri costituiti dalle nostre interfacce, più una…

Lezione 26 / 30
Enza Neri
Immagine di copertina

Vuoi avviare una nuova carriera o fare un upgrade?

Trova il corso Digital & Tech più adatto a te nel nostro catalogo!

Se volessimo ricapitolare quello che abbiamo imparato sul type system, potremmo sintetizzarlo così: TypeScript parte dai primitivi, i tipi nativamente presenti nel linguaggio JavaScript, e aggiunge un insieme di definizioni per vincolare la struttura di oggetti e array. Tali definizioni seguono una gerarchia nominale di tipi veri e propri costituiti dalle nostre interfacce, più una serie di tipi avanzati, prodotti per lo più dalla combinazione e trasformazione di altri tipi. 

In questa sezione vediamo i cosiddetti utility types, cioè dei tipi generici il cui unico scopo è manipolare altri tipi in modi che risultano particolarmente pratici o comuni; ne vedremo i più frequenti.

Anche qui, ti avvertiamo: seconda ed ultima carrellata! Anche in questo caso non ci sarà una progressiva esposizione del type system, ma piuttosto uno showcase delle opzioni a disposizione, in modo tale da lasciare un’idea abbastanza definita di ciò che è possibile realizzare con TypeScript. Nessuno si aspetta che tutti questi tipi siano conosciuti a memoria e, anzi, il fatto stesso di essere arrivati fin qui significa che i fondamentali sono acquisiti e da questo punto in poi ogni strumento in più è disponibile per consultazione. Cominciamo!

Abbiamo visto come rendere opzionali alcune o tutte le proprietà di un’interfaccia; questa operazione può essere svolta con il tipo Partial<T>, che rappresenta qualunque sottoinsieme delle proprietà del suo tipo parametro

Partial<T> è spesso utilizzato quando si vuole applicare una patch ad un oggetto preesistente, come negli esempi che seguono.

const defaultClientConf = {
  baseUrl: "/",
  timeoutMs: 1000,
  maxRetry: 3,
};

type Conf = typeof defaultClientConf;

const makeConf = (conf: Partial<Conf>): Conf => ({
  ...defaultClientConf,
  ...conf
});

const conf = makeConf({ maxRetry: 10 });
// {
//   baseUrl: "/",
//   timeoutMs: 1000,
//   maxRetry: 10
// }

Qui makeConf usa defaultClientConf come oggetto di partenza a cui applicare le customizzazioni per restituire la configurazione definitiva di un ipotetico client

Vediamo un altro esempio:

const copyWith = <T>(obj: T, diff: Partial<T>): T => ({
  ...obj,
  ...diff
});

const user = {
  id: 1,
  name: "John Doe",
  email: "[email protected]"
};

const userCopy = copyWith(user, { email: "[email protected]" });
// {
//   id: 1,
//   name: "John Doe",
//   email: "[email protected]"
// }

Qui usiamo Partial<T> per creare copie di oggetti con alcune proprietà diverse dalle originali: questo caso è a tutti gli effetti una generalizzazione del precedente, dove makeConf non è altro che copyWith con il parametro obj fissato a defaultClientConf

Se Partial<T> ci permette di ricavare un tipo con tutte proprietà opzionali, Required<T> fa l’opposto e rende tutte le proprietà obbligatorie. Questo tipo è sicuramente meno interessante del precedente, ma potrebbe tornare utile nel momento in cui avessimo un’interfaccia espressa con una serie di proprietà opzionali e volessimo obbligare il chiamante di una funzione ad esprimere tutti i valori esplicitamente.

Il tipo composto da proprietà readonly è espresso dall’utility Readonly<T>. Questa utility svolge un ruolo simile alla notazione as const e viene normalmente usata per indicare che non si muterà un oggetto: 

function cannotModify(obj: Readonly<{ prop1: number, prop2: string }>): void {
  obj.prop1 = 33; // Cannot assign to 'prop1' because it is a read-only property.
}

cannotModify({
  prop1: 1,
  prop2: "hello"
});

Qui abbiamo usato Readonly<T> per indicare nella firma di cannotModify che questa funzione non altererà l’oggetto passatole come argomento. Questo tipo di scelta semantica può essere molto utile quando si sta scrivendo una funzione e si vuole chiarire che questa non applica mutazioni ai suoi parametri

NB. La mutazione sul posto (in inglese: in place mutation), vale a dire la riassegnazione di nuovi valori a variabili e proprietà, è un’operazione computazionalmente economica ed efficiente, ma espone a diverse categorie di errori e problematiche; per questo motivo ha senso adottare strategie per evitarla o confinarla. Più avanti vedremo come diversi paradigmi di programmazione puntano ad evitare o confinare le mutazioni in place.

Il tipo che più fedelmente rappresenta un generico oggetto JavaScript è Record<K, T>, la cui implementazione è qualcosa di questo tipo: 

type Record<K extends string | number | symbol, T> = {
  [P in K]: T;
}

Essenzialmente, un Record<K, T> è un oggetto con le chiavi di tipo K e i valori di tipo T. Una volta definito un oggetto come Record<K, T> è possibile accedere alle sue proprietà solo con la notazione oggetto[“chiave”], poiché TypeScript, non conoscendo la struttura dell’oggetto, non ammette il riferimento diretto a una proprietà con il normale navigatore . :

type StringDictionary = Record<string, string>;
type Grouped<T> = Record<number, T[]>;

const dictionary: StringDictionary = {
  someProp: "someValue",
  otherProp: "otherValue"
};

const multiples: Grouped<number> = {
  1: [1, 2, 3],
  4: [4, 8, 12],
  7: [7, 14, 21]
};

const valueFromDic = dictionary["someProp"];
const valueFromMultiples = groups[1];

I record diventano interessanti quando usati con gli union types:

type Key = "a" | "b" | "c";
type Value = "hello" | "hi";

const a: Record<Key, Value> = {
  a: "hello",
  b: "hi",
  c: "hello"
}

Qui abbiamo usato un Record<K, T> per creare un oggetto che combina un insieme finito di chiavi con un insieme finito di valori. Molto pratico, no? 

Due tipi molto utili per estrarre proprietà da tipi esistenti sono Pick<T, K> e Omit<T, K>; il primo estrae un sottoinsieme con le chiavi elencate, il secondo senza

Questi due tipi sono utilissimi nel momento in cui si vuole operare solo con alcune proprietà di un oggetto, escludendone altre, senza riscrivere il tipo con cui si vuole lavorare:

interface User {
  id: number;
  username: string;
  email: string;
  description: string;
}

interface UserDb {
  select(id: Pick<User, "id"> | Pick<User, "email">): User;
  insert(user: Omit<User, "id">): User["id"];
  update(diff: Pick<User, "id"> & Partial<Omit<User, "id" | "username">>): void;
  delete(id: User["id"]): void;
}

function example(db: UserDb): void {
  // seleziona utente per id o email
  const u1 = db.select({ id: 1 });
  const u2 = db.select({ email: "[email protected]" });

  // aggiungi utente
  const id = db.insert({ username: "john_doe", email: "[email protected]", description: "Junior full-stack dev" });

  // aggiorna utente per id, senza modificare username
  db.update({ id, description: "Senior full-stack dev" });

  // elimina utente per id
  db.delete(id);
}

In questo esempio c’è un bel po’ di carne al fuoco: abbiamo implementato l’interfaccia di un database sul quale operare le cosiddette CRUD su un’entità User, e per ogni metodo abbiamo usato unioni e intersezioni insieme ai tipi utility appena visti per indicare esattamente quali proprietà e di quale tipo passare. 

TypeScript ci ha risparmiato di definire tutte queste interfacce separatamente, e in questo modo abbiamo raggiunto una granularità difficilmente ottenibile in altri linguaggi. 

Due tipi utili per lavorare con gli union types sono Exclude<A, B> e Extract<A, B>; il loro funzionamento di base è abbastanza ovvio:

type ABC = "a" | "b" | "c";
type BC = Exclude<ABC, "a">; // "b" | "c"
type A = Extract<ABC, "a">; // "a"

Queste due utilità danno il meglio quando vengono usate con tipi assegnabili a quelli di partenza: 

type Union =
  | { type: "A", payload: string }
  | { type: "B", payload: number }
  | { type: "C", payload: boolean }
  | null;

// estrae il primo object type
type A1 = Extract<Union, { type: "A" }>;
type A2 = Extract<Union, { payload: string }>;

// estrae i tre object type senza null
type ABC1 = Exclude<Union, null>;
type ABC2 = Extract<Union, {}>;

// estrae il secondo e il terzo object type
type BC1 = Extract<Union, { type: "B" | "C" }>;
type BC2 = Exclude<Union, { type: "A" } | null>;

Queste utility sono spesso usate per limitare a casistiche specifiche unioni che potrebbero essere più generiche del necessario. 

I seguenti tipi utility operano con funzioni e costruttori:

// estrae i parametri di un tipo funzione in una tupla
type A = Parameters<(a: number, b: number) => void>; // [a: number, b: number]
type B = Parameters<typeof Math.abs>; // [x: number]
type C = Parameters<typeof fetch>; // [input: RequestInfo | URL, init?: RequestInit | undefined]

// estrae i parametri di un costruttore
type D = ConstructorParameters<new (a: number) => unknown>; // [a: number]
type E = ConstructorParameters<typeof URLSearchParams> // [init?: string | string[][] | Record<string, string> | URLSearchParams | undefined]

// estrae il tipo di ritorno di una funzione
type F = ReturnType<typeof Math.random>; // number
type G = ReturnType<typeof console.log>; // void

// estrae l'istanza creata da un costruttore
// di solito, non è molto interessante
type H = InstanceType<typeof URLSearchParams>; // URLSearchParams

// estrae il valore atteso da una promise
type I = Awaited<Promise<number>>; // number

// estrae anche promise annidate e unioni di promise
type J = Awaited<Promise<Promise<string>> | boolean>; // string | boolean

// utile insieme a ReturnType
type K = Awaited<ReturnType<typeof fetch>>; // Response

Visto che in TypeScript le stringhe vengono spesso utilizzate come etichette per creare e discriminare tipi, esistono anche delle utility per manipolarle; ma, attenzione: questi tipi manipolano i tipi stringa letterali, non i loro effettivi controvalori!

type A = Uppercase<"abc">; // "ABC"
type B = Lowercase<A>; // "abc"
type C = Capitalize<"hey there">; // "Hey there"
type D = Uncapitalize<C>; // "hey there"

Siamo finalmente giunti alla fine di questa vastissima esplorazione. Nell’ultima parte di questa guida vedremo esempi e tecniche di applicazione pratica delle principali caratteristiche di TypeScript. In particolare, ci soffermeremo su come definire i nostri tipi e come organizzare il nostro codice di conseguenza, in base ai principali paradigmi di programmazione.

Sei indeciso sul percorso? 💭

Parliamone! Scrivici su Whatsapp e risponderemo a tutte le tue domande per capire quale dei nostri corsi è il più adatto alle tue esigenze.

Oppure chiamaci al 800 128 626