- Notifications
You must be signed in to change notification settings - Fork1
Support material for a workshop at Codemotion Workshop Fest 2022
License
pagopa-archive/codemotion-workshop-fest-2022--fpts
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
Code Sample supporting our workshop atCodemotion Workshop Fest 2022.
Agenda:
fp-ts
: cos’è e perché usarla- Tipi e operazioni essenziali
- Un caso reale: costruiamo un http endpoint
- Conclusioni e Q&A
Istruzioni
Gli esempi sono pensati per funzionare con Node.js 14. Per installare le dipendenze si può usare sia `npm` che `yarn`npm ci# oppureyarn install --frozen-lockfile
Build:
npm run build# oppureyarn build
Eseguire l'esempio:
npm run example01# oppureyarn example01
Gli esempi sono riportati insrc/walkthroughs
Facciamo una panoramica del set minimo di strumenti di cui abbiamo bisogno. Presenteremo:
- 3 Data Type
- 5 operazioni
Possiamo immaginare un Data Type come una scatola che contiene il valore che stiamo elaborando. Implementa le regole algebriche che ne determinano la componibilità, quindi le caratteristiche con cui il nostro valore può essere combinato con altri oggetti.
Si può immaginare un Data Type come le forme dei pezzi di un puzzle: gli "innesti" sono determinati sia dal Data Type stesso che dal tipo del valore contenuto.Possiamo quindi combinare Data Type i cui "innesti" sono compatibili tra loro. Inoltresiamo obbligati ad utilizzare tutti gli "innesti" esposti da un Data Type.
UnOption
serve ad esprimere la presenza o meno di un valore. Il caso d'uso tipico è la ricerca di un singolo elemento all'interno di una collezione, che può tornare uno o zero elementi; si usa anche per gestire parametri opzionali.
UnOption
è definito come l'unione di due tipi,Some
eNone
, che indicano la presenza o meno del valore:
typeOption<A>=None|Some<A>;
Diremo cheOption
èistanza di Some in presenza del valore,istanza di None nel caso contrario.
Il modo più elementare di costruire unOption
è usare gli appositi costruttori:
import*asOfrom"fp-ts/Option";O.some(42)// Option<number>O.none// Option<never>constmyVar:object;O.some(myVar)// Option<object>// ^^^^^ può essere null!!
Questo approccio può andare bene quandosiamo sicuri che il valore sia effettivamente presente.
Un altro modo, sicuramente più solido e quindi più usato, è quello di usare l'operazionefromNullable
che crea l'Option
a partire da un valore arbitrario:
constmyVar:object;O.fromNullable(myVar)// Option<object>// ^^^ può essere sia None che Some<object>, a seconda del contenuto della variabile
AnchefromNullable
può essere definito un costruttore, però aggiunge una certaintelligenza all'operazione. Ha senso quindi parlarne a parte, magari definendolo comesmart constructor.fp-ts
mette a disposizione molti smart contructor. In questa esposizione ne vedremo alcuni:fromEither
,fromOption
,fromPredicate
, etc. Li raggruppiamo nella categoriafrom*
.
Moduli e convenzioni di sintassi
Ad ogni Data Type corrisponde un modulo omonimo contenente i metodi e i tipi associati.In questa esposizione riproponiamo la convenzione che abbiamo usato in tutta la nostra codebase: importiamo tutti i metodi e gli attributi del modulo in una variabile che l'iniziale maiuscola del Data Type. Quindi:
import*asOfrom"fp-ts/Option";import*asEfrom"fp-ts/Either";import*asTEfrom"fp-ts/TaskEither";
Questo per avere un codice asciutto mantenendo il contesto dei metodi. Ad esempio, per usaremap()
avremo a disposioneO.map()
eE.map()
.
Un'alternativa è quella di usare il tipo come suffisso delle operazioni. A noi non piace e non la usiamo, ma è altrettanto valida.
import{mapasmapOption}from"fp-ts/Option";import{mapasmapEither}from"fp-ts/Either";
Infine, per le utility abbiamo la convenzione di importare le singole operazioni:
import{pipe,flow,identity}from"fp-ts/function";
Come si può utilizzare il valore contenuto dall'Option
? Il modo più diretto è accedere all'attributovalue
, che però è definito solamente per istanze diSome
. Occorre quindi utilizzare iltype narrowing di Typescript per ricondurci a quel caso:
constmaybeFoo=O.some("foo");maybeFoo.value;// ^^^^ Build error: value non è definito per Option<string>if(O.isSome(maybeFoo)){console.log(maybeFoo.value);// "foo"}else{console.error(maybeFoo.value);// ^^^^ Build error: value non è definito per None}
Le funzioniisSome
eisNone
sono delletype guard,fp-ts
ne mette a disposizione diverse a seconda del contesto e del Data Type. Le raggruppiamo nella categoriais*
.
Questo modo di accedere al valore contenuto lascia per strada molti dei vantaggi di usare un Data Type e quindi tendiamo ad evitare di usarlo. Tuttavia ha i suoi casi d'uso:
- quando si introduce
fp-ts
all'interno di una procedura scritta senza - nei test, dove si predilige immediatezza ed espressività rispetto alla solidità della procedura
Tramite questa operazione possiamo applicare una trasformazione al valore contenutosenza uscire dal contesto del Data Type:
consttoEuro=(n:number):string=>`€${n}`;consttoMaybeEuro=O.map(toEuro);// "eleva" toEuro per funzionare con OptiontoMaybeEuro(O.some(42));// Option<string>toMaybeEuro(O.none);// Option<string>
La funzionetoEuro
viene eseguita solo per istanza diSome
e ignorata per istanzeNone
.toMaybeEuro
invece è una funzione che accetta un'istanza diOption
e ne crea un'altra contenente il nuovo valore. È importante notare che la nuova istanza sarà a sua volta istanza diSome
e diNone
a seconda che l'Option
di partenza fosse rispettivamente unSome
o unNone
. In termini pratici,map conserva sia il Data Type che il tipo "base".
La sintassi usata può essere migliorata con l'utilitypipe
:
consttoEuro=(n:number):string=>`$${n}`;constresult=pipe(42,O.some,O.map(toEuro),O.map(value=>{console.log(value);// "€42"}));// Domanda: qual è il tipo di result?
pipe
accetta un valore e una serie di funzioniunarie (che accettano esattamente un parametro) che vengono eseguite in serie con il risultato della funzione precedente.Tramitepipe
è facile applicare più trasformazioni in serie:
constapplyDiscount=(perc:number)=>(n:number):number=>n*(1-perc/100);consttoRounded=(digits:number)=>(n:number):number=>Math.round(n*10**digits)/10**digits;consttoEuro=(n:number):string=>`$${n}`;pipe(myPrice,O.fromNullable,O.map(applyDiscount(35)),O.map(toRounded(2)),O.map(toEuro));
Anchechain
applica delle trasformazioni al valore contenuto, ma a differenza dimap
può cambiare se il risultato sia istanza diSome
oNone
. Si utilizza quando si vogliono mettere in serie due o più valori che potrebbero essere null-ish.
typeProduct={name:string;price:number;};constproducts=newMap<string,Product>();constgetFinalPrice=(productId:string):O.Option<string>=>pipe(productId,O.fromPredicate(s=>s.length>0),// Un altro smart constructor!O.chain(id=>{constproduct=products.get(id);returnproduct ?O.some(product) :O.none;// o meglio: O.fromNullable(product)}),O.map(product=>product.price),O.map(applyDiscount(35)),O.map(toRounded(2)),O.map(toEuro));
Con questa operazione entrambi i "rami" dell'elaborazione (Some
eNone
) vengono collassati in un unico ramo. Il risultato può essere un valore o un altro Data Type su cui lavorare.Un caso di utilizzo è quando si vuole di uscire dal contesto del Data Type per lavorare direttamente sul valore.
pipe(productId,getFinalPrice,O.fold(()=>"Cannot find product, sorry :(",price=>`You will pay${price}`),console.log// Domanda: cosa scrive sul log?)
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
eRight
; la sua definizione quindi sarà
typeEither<L,R>=Left<L>|Right<R>
Sebbene in teoria non ci sia differenza di significato tra i due rami, la convezione è che il ramoRight
esprima lohappy path mentre il ramoLeft
sia dedicato alla gestione degli errori.
I casi d'uso più comuni diEither
sono la validazione dell'input e il risultato di operazioni che possono fallire.
Come si usare unEither
? In maniera del tutto simile aOption
. Possiamo creare unEither
tramite i suoi costruttori:
import*asEfrom"fp-ts/Either";E.right(42);E.left("not 42");constvalidatePrice=(price:number):E.Either<string,number>=>price>=0 ?E.right(price) :E.left("price cannot be negative")
O usare degli appositismart constructor:
// a partire da un'istanza di Optionpipe(O.some(42),E.fromOption(()=>"cannot handle null-ish values"))// o da operazioni che possono fallire sollevando un'eccezioneE.tryCatch(()=>JSON.parse('{"baz":true}'),exception=>newError());
È importante notare che in entrambi i casi è stato necessario fornire allosmart constructor istruzioni su come gestire il ramo negativo.
Anche suEither
sono definite le operazioniis*
,map
,chain
efold
:
constfooOrError=E.right("foo");if(E.isRight(fooOrError)){console.log(fooOrError.right);console.log(fooOrError.left);// ^^^^ Build error: left non è definito per Right}else{console.log(fooOrError.left);console.log(fooOrError.right);// ^^^^ Build error: right è definito per Left}constcheckMinPrice=(price:number):E.Either<string,number>=>price>=10// arbitrary treshold, just an example ?E.right(price) :E.left("price cannot be less than 10");pipe(price,validatePrice,E.map(applyDiscount(23)),E.chain(checkMinPrice),E.foldW(reason=>newError(reason),toEuro))
Perché `foldW`?
Perché il tipo tornato dal caso left è diverso dal caso right. `W` sta per Wide, ovvero "allarga" il tipo per accogliere entrambi. In pratica: una union.Un particolarità diEither
rispetto adOption
è la presenza dimapLeft
: stesso concetto dimap
ma applicato al ramo negativo. Si usa molto spesso per mappare gli errori su tipi coerenti a tutta la pipe:
pipe(price,validatePrice,E.mapLeft(failure=>newError(`Validation error:${failure}`))E.map(applyDiscount));
Attenzione conmapLeft
: si applica aqualsiasiLeft
precedente, non solo a quello prodotto dalchain
immediatamente sopra. Ad esempio:
pipe(1,// checkMinPrice falliràvalidatePrice,E.mapLeft(failure=>newError(failure)),E.chain(checkMinPrice),E.mapLeft(failure=>{// ^^^ Error | string// failure può arrivare sia da validatePrice che da checkMinPrice}));
Un modo consueto di risolvere è usare delle pipe innestate:
pipe(1,validatePrice,E.mapLeft(failure=>newError(failure)),E.chain(price=>pipe(price,checkMinPrice,E.mapLeft(failure=>{// ^^^ failure può arrivare SOLO da checkMinPrice}))));
TaskEither
l'ultimo Data Type che includiamo tra gli essenziali. Rappresentaun'operazione asincrona che può fallire e, come si intuisce dal nome, è definito come unTask
che ritorna unEither
typeTask<T>=()=>Promise<T>typeTaskEither<L,R>=()=>Promise<Either<L,R>>
Fondamentale evidenziare cheunTaskEither
ritorna una Promise che non fallisce mai; l'eventuale fallimento sarà rappresentato dal ramoLeft
.
Possiamo maneggiare unTaskEither
esattamente come faremmo con unEither
:
import*asTEfrom"fp-ts/TaskEither";TE.right(42);TE.left("not 42");TE.tryCatch(()=>Promise.resolve(42),failure=>newError("unknown failure"));constprocessPayment=async(price:number)=>{returnprocessPaymentOnProvider();}constprocedure=pipe(myPrice,validatePrice,E.map(applyDiscount(30)),E.chain(checkMinPrice),TE.fromEither,TE.chain(actualPrice=>TE.tryCatch(()=>processPayment(actualPrice),err=>"è successo qualcosa durante il pagamento")),TE.map(_=>"OK"),// questo fold è sostituito da TE.toUnionTE.fold(_=>async()=>_,_=>async()=>_));procedure().then(result=>{console.log(result)});
Data Type | Si usa per |
---|---|
Option | Un valore che c'è o è null-ish |
Either | Validazione, operazione che può fallire |
TaskEither | Operazioneasincrona che può fallire |
Operazione | Si usa per |
---|---|
is* | type narrowing di un TypeClass in un sotto-tipo |
from* | Costruire un Data Type a partire da un valore o un altro Data Type |
map | Applicare una trasformazione al valore contenuto senza cambiare il sotto-tipo |
chain | Applicare una trasformazione al valore contenuto cambiando il sotto-tipo |
fold | Far convergere i due rami della computazione |
Esporre un endpoint che permetta di sottomettere un Talk per uno Speaker.
Il Talk deve avere un titolo lungo tra i 30 e i 70 caratteri e una descrizione di massimo 3000 caratteri.
Un Talk deve far riferimento ad uno Speaker già registrato e confermato.
Specifica OpenAPI
openapi:3.0.3info:title:Exampleversion:0.1.0paths:/talks:post:operationId:SubmitTalkresponses:200:description:Submission acceptedcontent:application/json:schema:$ref:"#/components/schemas/Submission"400:description:Invalid parameterscontent:text/plain:schema:type:string403:description:Speaker not confimed404:description:Speaker not found500:description:Server Errorcontent:text/plain:schema:type:stringcomponents:schemas:Submission:type:objectproperties:title:type:stringabstract:type:stringspeakerId:type:string
exportdefault(input:unknown):Promise<EndpointResponse>=>pipe(input,// validate submissionvalidateSubmission,TE.fromEither,TE.mapLeft(reason=>invalidParameters(reason)),// check if the speaker exists and is confirmedTE.chain(sub=>pipe(TE.tryCatch(()=>readSpeakerById(sub.speakerId),_=>serverError("failed to retrieve speaker informations")),TE.chain(maybeSpeaker=>pipe(maybeSpeaker,O.fromNullable,TE.fromOption(()=>speakerNotFound()))),TE.chain(speaker=>speaker.confirmed ?TE.right(speaker) :TE.left(speakerCannotSubmit())),TE.map(_=>sub))),TE.chain(sub=>TE.tryCatch(()=>saveTalk({abstract:sub.abstract,speakerId:sub.speakerId,title:sub.title}),_=>serverError("failed to save talk"))),TE.map(sub=>success(sub)),TE.toUnion)();
About
Support material for a workshop at Codemotion Workshop Fest 2022
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Releases
Packages0
Uh oh!
There was an error while loading.Please reload this page.
Contributors2
Uh oh!
There was an error while loading.Please reload this page.