La programmazione funzionale per creare servizi digitali efficienti

Perché in PagoPA utilizziamo la programmazione funzionale con TypeScript (fp-ts) per raggiungere le migliori soluzioni per i cittadini

Emanuele De Cupis
PagoPA SpA
9 min readSep 1, 2022

--

Quante volte avete sentito dire che un bravo programmatore lo valuti dall’attitudine e dal talento, che “tanto il linguaggio lo impari”, che l’importante è padroneggiare i concetti e la capacità di apprendere?

In PagoPA crediamo in questo approccio, e infatti nei nostri team puoi trovare persone con background molto diversi tra loro: c’è chi ha tutte le certificazioni .NET, chi ha una carriera nel mondo Java Enterprise, chi come full-stack in JavaScript e PHP.

Questa diversità ci arricchisce molto perché aumenta la nostra capacità di trovare soluzioni efficaci, ma ha un costo: ognuno si porta dietro sia le buone pratiche che i vizi della propria community di riferimento.

Ci siamo quindi impegnati per trovare strumenti e metodologie che ci aiutassero a convergere su uno stile di programmazione riconoscibile e apprezzabile da tutti, che permettesse ad ognuno di esprimere le proprie qualità e al tempo stesso offrisse una standardizzazione del codice prodotto.

In questo post vi raccontiamo come la programmazione funzionale con TypeScript (fp-ts) ci ha permesso di rispondere a questa esigenza, creando team di sviluppo solidi, basati su competenze diverse. Un approccio che risponde alla cultura della nostra società PagoPA: avere un sistema di tipi e regole da comporre ci permette di focalizzarci sulla ricerca delle soluzioni migliori per risolvere i problemi dei cittadini, senza sprecare energie sulle valutazioni dei diversi modi di scrivere il codice per perseguire questo obiettivo.

Il ruolo della programmazione funzionale

Chiariamo subito un concetto: per noi la programmazione funzionale è un mezzo per costruire soluzioni, non un fine da raggiungere. Ci piace avere un approccio pragmatico e semplice, a tratti volutamente semplicistico; tenendo sempre il focus su come risolvere il problema.

Con questo approccio, cos’è per noi la programmazione funzionale? È un set di regole formalizzate e verificate che governano la composizione. Dati due oggetti A e B, risponde alle domande:

  • possono comporsi?
  • in quali modi si compongono?
  • che significato assume la loro composizione?

Essendo la componibilità un fattore importante per la bontà del codice, le promesse di una developer experience di qualità sono alte.

Se abbiniamo questo motore di regole ad un software che, esaminando il nostro codice, riesce a individuare falle nella logica che scriviamo, in pratica abbiamo a disposizione un “assistente virtuale” che ci guida nella scrittura. Nel nostro caso, questo software è TypeScript (TS): ecco perché l’approccio che utilizziamo è FP+TS=fp-ts.

Il modello mentale

Prima di entrare nel dettaglio delle funzionalità offerte da fp-ts, proviamo a raccontare il modello mentale su cui proviamo a convergere, che sta alla base di questo approccio.

Railway-oriented programming è una tecnica di programmazione che permette di scrivere applicazioni robuste grazie ad una gestione attenta dei casi d’errore, sfruttando i principi di composizione della programmazione funzionale.

Proviamo a spiegare con una serie di immagini questa tecnica, semplice ma molto potente:

Una funzione è un “collegamento” tra due tipi; un ramo o, appunto, un binario.
Se il tipo di output della prima funzione è compatibile con il tipo di input della seconda, le due funzioni possono comporsi
Nella realtà una funzione può anche fallire. Vanno quindi considerati due diversi tipi di output: il tipo per il caso di successo e per il caso di fallimento. Rimanendo nella metafora delle rotaie, la prima funziona avrà due binari di uscita. Come comporla con la seconda, che accetta in input un solo binario? Come gestire il caso d’errore?
La soluzione è scrivere la seconda funzione affinché accetti due binari in input. In questo modo la composizione è formalmente corretta, e i casi di errori sono espliciti al programmatore. Nell’esempio, il binario di fallimento della seconda funzione effettua un semplice pass-through dell’eventuale errore della prima.
Sfruttando questo meccanismo, possiamo comporre degli algoritmi complessi a piacere con la sicurezza che siano corretti.

Primi passi di programmazione funzionale con TypeScript

Proponiamo ora una panoramica delle funzionalità di fp-ts che per noi sono essenziali, ovvero quelle poche funzionalità con cui, per la nostra esperienza, riusciamo a coprire una grande parte di esigenze.

Anche qui non vogliamo banalizzare la vastità dell’API offerta dalla libreria, ma offrire un punto di partenza concreto da cui iniziare ad usare fp-ts per risolvere problemi reali.

Cos’è un Data-Type?

Possiamo pensare ad un Data-Type come ad un “contenitore” in cui tenere i valori della nostra elaborazione. Il ruolo del Data-Type è quello di assegnare un significato ed un contesto al nostro valore, ovvero di implementare le regole di programmazione funzionale che definiscono come questo valore può comporsi con gli altri. In fp-ts un Data-Type è implementato tramite una struttura dati.

Data-Type: Option

Il primo Data-Type che introduciamo è Option. Option esprime la presenza o l’assenza di un valore. I casi d’uso tipici sono la presenza o meno di un elemento in una collezione e la gestione di parametri opzionali.

Da un punto di vista formale, Option è definito come l’unione tra i tipi Some e None; parleremo quindi di istanza Some e istanza None di Option, rispettivamente, se il valore è presente o meno.

Il modo più elementare di costruire un Option è usare gli appositi costruttori:

Ritornando per un istante al modello di Railway-oriented programming introdotto al paragrafo precedente, possiamo immaginare un Option come un oggetto con due binari: il binario Some (happy path) e il binario None (unhappy path).

Operazione: smart constructors

Gli smart constructors sono una famiglia di operazioni che, a partire da un valore o da un Data-Type, costruiscono un nuovo Data-Type. La parola smart li differenzia dai normali costruttori proprio perché queste utility applicano logiche intelligenti che risolvono casistiche specifiche.

Uno smart constructor per Option è fromNullable, che a seconda del valore crea un’istanza Some o None.

Altri smart constructor sono: fromEither, fromOption, fromPredicate, tryCatch, etc. Notare che gli smart constructor sono definiti differentemente per ogni Data-Type:

Operazione: type guard

Le type guard sono le operazioni definite per i Data-Type che possiamo riconoscere dal prefisso is-:

Sostanzialmente, permettono di fare narrowing del tipo specifico di istanza di un Data-Type. Sono molto utilizzati quando ci troviamo ad utilizzare un Data-Type in un contesto di codice procedurale per accedere al valore contenuto:

Vedremo di seguito come questa esigenza sarà superata da un approccio più incline al paradigma funzionale.

Operazione: map

Tramite questa operazione possiamo applicare una trasformazione al valore contenuto nel happy path di un Data-Type. Il ruolo di map è quello di elevare una funzione di trasformazione tra valori ad una funzione di trasformazione tra Data-Type. Un esempio banale:

Nell’esempio, toEuro è una funzione f: Number → String. Tramite map possiamo convertirla in una funzione map(f): Option<Number> → Option<String>. Questa operazione di conversione si chiama type-lifiting.

Il type-lifting può risultare verboso e macchinoso se usato come nell’esempio, ma l’esperienza migliora molto se usiamo l’utility pipe offerta da fp-ts.

pipe accetta un valore e una serie di funzioni unarie (che accettano esattamente un parametro) che vengono eseguite in serie con il risultato della funzione precedente. Tramite pipe è facile applicare più trasformazioni in serie:

Operazione: chain

Anche chain applica delle trasformazioni al valore contenuto, ma a differenza di map può cambiare anche il tipo dell’istanza. Per fare un esempio utilizzando Option:

L’operazione chain in alcuni linguaggi è nota come flatMap.

Da notare che il caso d’uso è quando un ramo positivo può diventare un ramo negativo. Per il caso opposto, in cui un ramo negativo può diventare positivo, dobbiamo introdurre una nuova operazione: fold.

Operazione: fold

Con questa operazione entrambi i “rami” dell’elaborazione vengono collassati in un unico ramo. Il risultato può essere un valore o un altro Data Type su cui lavorare: il primo caso è quando vogliamo estrarre il lavoro per manipolarlo fuori dal contesto dei Data-Type; il secondo caso è quello anticipato nel paragrafo precedente, ovvero quando un Data-Type che si trova nel ramo negativo può portarsi nel ramo positivo.

Data-Type: Either

Introduciamo un nuovo Data Type su cui operare: Either. Either esprime il risultato di una computazione che può essere esclusivamente di un tipo o di un altro. In pratica divide la computazione in due rami, Left e Right; la sua definizione quindi sarà:

Sebbene in teoria non ci sia differenza di significato tra i due rami, la convezione è che il ramo Right esprima lo happy path mentre il ramo Left sia dedicato alla gestione degli errori.

I casi d’uso più comuni di Either sono la validazione dell’input e il risultato di operazioni che possono fallire.

Come si usa un Either? In maniera del tutto simile a Option. Possiamo creare un Either tramite i suoi costruttori:

O usare degli appositi smart constructor:

È importante notare che in entrambi i casi è stato necessario fornire allo smart constructor istruzioni su come gestire il ramo negativo.

Anche per Either, come per Option, sono definite le operazioni map, chain e fold — con lo stesso significato e casi d’uso:

Un particolarità di Either rispetto ad Option è la presenza di mapLeft: stesso concetto di map ma applicato al ramo negativo. Si usa molto spesso per mappare gli errori su tipi coerenti a tutta la pipe:

Attenzione con mapLeft: si applica a qualsiasi Left precedente, non solo a quello prodotto dal chain immediatamente sopra. Ad esempio:

Un modo consueto di risolvere è usare delle pipe annidate:

Infine, anche Either può essere usato in un contesto procedurale sfruttando le type guard specifiche:

Data-Type: TaskEither

TaskEither è l’ultimo Data Type che includiamo tra gli essenziali. Rappresenta un’operazione asincrona che può fallire e, come si intuisce dal nome, è definito come un Task che ritorna un Either.

Fondamentale evidenziare che un TaskEither ritorna una Promise che non fallisce mai; l’eventuale fallimento sarà rappresentato dal ramo Left.

Possiamo maneggiare un TaskEither esattamente come faremmo con un Either:

Cheatsheet

Proviamo a riassumere quanto raccontato in questo articolo in un ricettario pronto all’uso

Programmazione funzionale al servizio dei cittadini

Vi abbiamo raccontato la nostra esperienza con la programmazione funzionale, nello specifico nello stack TypeScript con fp-ts. Abbiamo provato a raccontare in termini pratici come sia possibile approfittare dei suoi benefici, speriamo di aver trovato il giusto compromesso tra una narrazione semplice e al contempo coerente e istruttiva.

I concetti e gli strumenti che abbiamo esposto sono la punta dell’iceberg di quanto offerto da fp-ts e dalla programmazione funzionale in generale. Sono però, a nostro avviso, quanto basta per cominciare a utilizzarli in progetti reali con un approccio incrementale.

Non vogliamo essere fraintesi: tutto ciò che è stato detto va letto sotto la lente dell’esperienza personale. Ogni team e organizzazione ha la sua sensibilità, e quello che per noi rappresenta un vantaggio potrebbe non esserlo per altri.

Inoltre le considerazioni non sono scolpite nella pietra, ma variano nel tempo, tanto più che cambiano le esigenze e impariamo nuove cose.

Con queste dovute premesse, possiamo comunque riportare qualche risultato che abbiamo raggiunto in PagoPA grazie alla programmazione funzionale con TypeScript:

  • l’app IO è interamente scritta in Typescript e fp-ts, sia l’app mobile che i servizi backend;
  • il numero di bug legati alla scrittura di codice è trascurabile;
  • la codebase ha circa 4 anni, e diverse persone si sono alternate come anche si sono stratificate implementazioni, esigenze e decisioni; tuttavia, fp-ts ha accompagnato bene l’evoluzione della codebase;
  • persone differenti riescono a convergere su uno stile unico di scrittura, aumentando la condivisione, facilitando le code-review.

Non mancano punti di frizione:

  • È necessaria la conoscenza di fp-ts, per quanto piccola, per riuscire a leggere e capire codice. Se questo per i membri del team non è un problema, in quanto la formazione fa parte del processo di onboarding, lo è per colleghi di altri team che non sono abituati allo stack.
  • È difficile introdurre nuovi Data-Type e operazioni. Sebbene portino un beneficio in termini di componibilità ed efficienza, abbiamo notato come questo riduca notevolmente la capacità dei membri del team di comprendere quanto scritto.

Riferimenti

--

--