Typescript e la programmazione funzionale | Aulab

GUIDE PER ASPIRANTI PROGRAMMATORI

Typescript e la programmazione funzionale

Abbiamo visto le tecniche proposte dalla programmazione orientata agli oggetti per organizzare dinamicamente dati e funzioni e discriminare tra diverse casistiche; vediamo, ora, come le stesse problematiche vengono affrontate in programmazione funzionale.  Ripartiamo, dunque, dal nostro solito contatore e vediamo come questo verrebbe gestito in FP. Poiché qui le funzioni sono intese in senso matematico,…

Lezione 30 / 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!

Abbiamo visto le tecniche proposte dalla programmazione orientata agli oggetti per organizzare dinamicamente dati e funzioni e discriminare tra diverse casistiche; vediamo, ora, come le stesse problematiche vengono affrontate in programmazione funzionale

Ripartiamo, dunque, dal nostro solito contatore e vediamo come questo verrebbe gestito in FP. Poiché qui le funzioni sono intese in senso matematico, generalmente queste non operano mutazioni sui dati, ma invece restituiscono il risultato di un calcolo come valore di ritorno. Ad ogni modo, anche in FP esiste un modo per rappresentare e incapsulare uno stato in modo simile a come fatto inizialmente a livello di moduli. In pratica, vedremo prima un approccio stateless o statico, e poi uno stateful o dinamico, più simile ad una architettura OOP. 

Per l’approccio stateless, l’idea è di delegare la conservazione dello stato al chiamante e fornire tutte le funzioni che permettono di riassegnare quello stato; il nostro modulo counter.ts diventa dunque:

type Factory = () => number;
type Operator = (counter: number) => number;

export const Counter: Factory = () => 0;
export const increment: Operator = counter => counter + 1;

In questo esempio, la funzione reset è sparita, perché sarebbe identica a Counter, e questo risulta evidente quando guardiamo al codice chiamante: 

import { Counter, increment } from "./counter";

const incrementBtn = document.querySelector("#increment") as HTMLButtonElement;
const resetBtn = document.querySelector("#reset") as HTMLButtonElement;
const counterSpan = document.querySelector("#counter") as HTMLSpanElement;

let counter = Counter();

incrementBtn.addEventListener("click", () => {
  counter = increment(counter);
  update();
});


resetBtn.addEventListener("click", () => {
  counter = Counter();
  update();
});

function update(): void {
  counterSpan.innerText = String(counter);
}

Osserviamo che:

  • la natura mutevole dello stato è ora esplicita poiché counter non è più costante e il suo valore viene direttamente riassegnato e letto dal nostro codice chiamante
  • le funzioni del modulo counter.ts lavorano sul tipo number esclusivamente restituendo un nuovo risultato, senza mai mutare uno stato; 
  • a parità di parametri, le nostre funzioni restituiscono sempre lo stesso risultato, quindi sono al 100% prevedibili.

Questo approccio puro presenta molti benefici in termini di determinismo: una volta scritto e testato, un modulo sarà sempre composto esclusivamente di funzioni pure statiche il cui comportamento non dipende da alcun contesto.

Rispetto all’implementazione OOP, questa versione ha una grossa falla: abbiamo esposto la gestione del nostro stato e, ora, qualunque parte del codice potrebbe mutarlo in modi inconsistenti, ad esempio portandolo da 0 a 1000 in una sola operazione, oppure assegnandogli un valore minore di zero. La verità è che questa caratteristica dell’approccio FP è tendenzialmente intenzionale: differentemente dall’OOP, in FP i dati sono sempre informazioni passive, dunque la possibilità di estendere l’insieme di operazioni che si possono effettuare su un dato è considerata un vantaggio, mentre per proteggere il nostro stato da operazioni inconsistenti si tende ad utilizzare il type system, che ha il vantaggio di essere controllato in compilazione e dunque di avvisarci in anticipo di possibili errori. 

Ovviamente, questo non significa che non possiamo fare incapsulamento in FP; significa solo che le tecniche utilizzate per ottenerlo sono diverse. Abbiamo detto che anche in FP possiamo adottare un approccio stateful, e il modo per farlo sono le closures (chiusure). In realtà, abbiamo già incontrato un esempio di incapsulamento ottenuto con una closure: il primo esempio di modulo counter.ts nella sezione sui moduli. L’implementazione proposta da quell’esempio aveva il limite di non poter gestire lo stato in modo dinamico, ad esempio supportando l’esistenza di più contatori. 

Vediamo come superare questo limite, riscrivendo così il nostro counter.ts:

export type Instance = {
  count: () => number;
  increment: () => void;
  reset: () => void;
};

export type Factory = () => Instance;

export const Counter: Factory = () => {
  let state = 0;

  return {
    count: () => state,
    increment: () => { state++ },
    reset: () => { state = 0 },
  };
};

Il motivo per cui il corpo increment e reset è racchiuso tra graffe è che vogliamo esprimere che tali funzioni ritornino void anziché il risultato di state++ (che sarebbe il valore di state prima dell’incremento) o di state = 0 (che sarebbe 0). 

Il codice che consuma questo modulo è il seguente: 

import { Counter } from "./counter";

const incrementBtn = document.querySelector("#increment") as HTMLButtonElement;
const resetBtn = document.querySelector("#reset") as HTMLButtonElement;
const counterSpan = document.querySelector("#counter") as HTMLSpanElement;

const { count, increment, reset } = Counter();

incrementBtn.addEventListener("click", () => {
  increment();
  update();
});


resetBtn.addEventListener("click", () => {
  reset();
  update();
});

function update(): void {
  counterSpan.innerText = String(count());
}

Notiamo come questo esempio sia la perfetta via di mezzo tra l’esempio del modulo e l’esempio OOP: anche questa volta il risultato di Counter è un oggetto creato dinamicamente, che abbiamo destrutturato nei suoi metodi; questa volta, però, i metodi non mutano delle proprietà di un oggetto come nella classe, bensì una variabile incapsulata nella funzione Counter, che si comporta come se fosse il costruttore di una classe, ma viene chiamata senza l’operatore new.

Attenzione: il risultato di Counter() può essere destrutturato come se si trattasse di un modulo statico o di un normale oggetto, mentre nel caso OOP equivalente quest’operazione non si deve mai fare

C’è una differenza chiave tra il mutare le proprietà di una classe e il mutare una variabile nascosta da una closure: la parola chiave this. I metodi di una classe non sono closures, ma funzioni che utilizzano la variabile speciale this per legarsi all’istanza su cui vengono chiamati. 

Destrutturando un’istanza di classe spezziamo quel legame, e facendolo ne rompiamo i metodi.

A questo punto, dovremmo anche aver chiarito perché si parla di chiusure o closures: le funzioni restituite dalla funzione Counter sono in grado di catturare la variabile state e continuare a manipolarla anche dopo che la funzione Counter ha concluso la sua esecuzione; in pratica, come per magia, abbiamo prolungato la vita di una variabile al di fuori dello scope in cui è nata, e stiamo usando quello scope catturato come se fosse un oggetto inaccessibile, se non attraverso le funzioni restituite da quello scope

In questo modo abbiamo reso il nostro Counter perfettamente incapsulato e dunque “inestensibile”, nel senso che il suo stato non sarà in alcun modo manipolabile, se non secondo le operazioni fornite dal suo “costruttore”.

NB. In realtà, in FP, non si parla tanto di costruttori ma di factory cioè fabbriche. La differenza è che un costruttore inizializza le proprietà di una classe in modo procedurale, mentre una factory restituisce un oggetto o un’istanza di un qualcosa. In generale, una factory potrebbe anche restituire l’istanza di una classe, nascondendone l’istanziamento. 

Le factory presentano un grande vantaggio rispetto ai costruttori: possono restituire più di un tipo, ad esempio null se i valori passati per l’inizializzazione non sono validi; un costruttore può solo limitarsi a scatenare un errore, interrompendo l’esecuzione del programma.

Come abbiamo visto, le closures presentano moltissime sovrapposizioni con le classi; in ogni caso, in FP si preferisce generalmente adottare un approccio stateless e spostare la mutazione dei dati al di fuori di moduli di calcolo. Non solo questo li rende più testabili, ma anche meno inclini a errori dovuti alla condivisione dello stato tra diverse aree del codice. 

Il branding dei tipi in Typescript

Prima di avviarci a concludere questa guida, vediamo un’ultima tecnica particolarmente diffusa in TypeScript, che permette di qualificare a livello di type system un dato senza alterarne il reale contenuto a runtime: il branding (marchiatura) dei tipi. Con i branded types si ottiene una forma di validazione a compile tyme sfruttando le capacità del type checker, evitando, così, di ricorrere agli errori e, dunque, di alterare il normale flusso di esecuzione di una funzione. 

Riprendiamo l’esempio della classe User vista in precedenza: attraverso il setter della proprietà age, questa classe si assicura che l’età di un utente non sia mai impostata con un valore negativo. Questo significa che un blocco di codice chiamante potrebbe causare un errore a runtime assegnando il valore sbagliato. 

In FP l’utilizzo di throw non è molto apprezzato, perché comporta una variazione del normale flusso di input/output che caratterizza le funzioni pure, e si comporta più come un effetto collaterale. Quello che invece si tende a fare, è introdurre il vincolo a livello di type system

Dunque proviamo a scrivere in FP una factory di User avvalendoci di un branded type per l’età, che rappresenta solo numeri interi maggiori di zero; il nostro modulo user.ts avrà questo aspetto:

// TypeScript accetta l'intersezione tra 'number' e un object literal
// allo scopo esplicito di abilitare la creazione di un branded type.
type NonZeroInteger = number & { __kind: "non_zero_integer" };

// Ora il vincolo non è più nei metodi della classe ma nel tipo.
type User = {
  name: string;
  age: NonZeroInteger;
};

// L'unico modo valido per creare un oggetto di tipo User
// è passare da questa funzione che marchia un numero come 
// intero e maggiore di zero.
const NonZeroInteger = (x: number): NonZeroInteger | null =>
  Number.isInteger(x) && x > 0
    ? x as NonZeroInteger
    : null;

Vediamo come si comporta il codice che consuma questo modulo: 

const invalid: User = { name: "John", age: 32 }; // Type 'number' is not assignable to type 'NonZeroInteger'.

// Ora il codice chiamante è costretto a fare una verifica
const age = NonZeroInteger(32);

const stillInvalid: User = { name: "John", age }; // Type 'NonZeroInteger | null' is not assignable to type 'NonZeroInteger'.

if (age !== null) {
  const valid: User = { name: "John", age }; // OK
}

In pratica, anziché risolvere a never l’intersezione tra number e un object literal, TypeScript lascia aperta la possibilità consentendoci di marchiare un numero con una proprietà che di fatto non esiste se non in compilazione, e dunque solo per il tempo necessario ad effettuare i type check prima di sparire nel codice JavaScript risultante. Piuttosto ingegnoso! 

Ovviamente, il concetto di marchiatura dei tipi può essere generalizzato; facciamolo con un modulo branded.ts:

export type Branded<T, B extends string> = T & { __brand: B };
export const brand = <T, B extends string>(value: T, _brand: B): Branded<T, B> => value as Branded<T, B>

In questo modulo, il tipo Branded rappresenta un qualunque tipo brandizzato con una stringa; qui la stringa è rappresentata dal parametro di tipo B; la funzione brand marchia il tipo di una qualunque variabile usando la stringa fornita dal parametro _brand; notare che questo parametro non viene mai realmente utilizzato nel corpo della funzione, ma ha il solo scopo di permettere al type checker di marchiare value

Questo semplicissimo modulo contiene lo stretto indispensabile per brandizzare comodamente qualunque cosa. Ovviamente ha senso solo se usato insieme ad una funzione che converta un tipo concreto nella sua controparte marchiata dopo aver verificato che i vincoli rappresentati dal marchio siano rispettati. 

Ad ogni modo, questo approccio presenta ancora un problema: 

import { brand } from "./branded";

const brandedFive = brand(5, "five");
console.log(brandedFive.__brand); // OK

In questo codice c’è una falla: la proprietà __brand non esiste davvero su brandedFive (che non è altro che un 5), eppure TypeScript la accetta, condannandoci ad un TypeError garantito in esecuzione!

Per risolvere questo problema possiamo ricorrere ad uno stratagemma speciale, che coinvolge due concetti avanzati mai affrontati in precedenza in questa guida: i simboli e la parola chiave declare.

  • Un symbol è un tipo primitivo speciale che si usa nel linguaggio JavaScript per creare delle chiavi uniche sugli oggetti. Le proprietà create con un symbol possono essere lette solo da chi possiede un riferimento a tale simbolo, conservato in una variabile; quindi, non avremo modo di navigare alla proprietà che identifica il marchio del nostro tipo e questo resterà utilizzabile solo in termini di type check
  • Con la keyword declare si può istruire TypeScript sull’esistenza di una variabile in un dato contesto, senza che in quel contesto la v ariabile compaia effettivamente; è grazie a declare che TypeScript conosce tutte le API JavaScript del DOM, ad esempio, che sono fornite dal browser senza essere mai dichiarate esplicitamente in alcun codice JavaScript. 

Vediamo, dunque, come diventa il nostro modulo branded.ts

// Questo 'unique symbol' non esiste davvero, se non a compile time!
declare const __brand: unique symbol;

// La proprietà identificata dalla chiave [__brand] è anch'essa inesistente,
// ma a differenza della sua controparte stringa, questa è anche inaccessibile.
export type Branded<T, B extends string> = T & { [__brand]: B };

export const brand =
  <T, B extends string>(value: T, _brand: B): Branded<T, B> =>
    value as Branded<T, B>;

Tutto il resto è uguale, eccetto una cosa: 

import { brand } from "./branded";

const brandedFive = brand(5, "five");
console.log(brandedFive.__brand); // Property '__brand' does not exist on type 'Branded<5, "five">'.

Ora TypeScript non è in grado di identificare alcuna proprietà che rappresenti il marchio della nostra variabile, quindi siamo protetti da ogni errore.

I tipi marchiati sono un argomento che coinvolge molti concetti complessi, e sono riportati in questa guida esclusivamente per due ragioni: la prima è semplice amor di completezza; la seconda è per rappresentare senza sconti l’ingegnosità e l’astrazione su cui si tende a operare quando si fa programmazione funzionale. Questo non deve, tuttavia, spaventare: si tratta semplicemente di un approccio più formale e rigoroso, fortemente ispirato alla matematica; ci sono diverse librerie che implementano le tecniche mostrate, e possiamo adottarle senza preoccuparci più di tanto di come siano implementate, purché ci sia chiaro il loro scopo.

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