Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings
This repository was archived by the owner on Jan 15, 2025. It is now read-only.

Support material for a workshop at Codemotion Workshop Fest 2022

License

NotificationsYou must be signed in to change notification settings

pagopa-archive/codemotion-workshop-fest-2022--fpts

Repository files navigation

Code Sample supporting our workshop atCodemotion Workshop Fest 2022.

fp-ts: l’approccio pragmatico usato da App IO

Agenda:

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

fp-ts: cos’è e perché usarla

Vedi slides.

Tipi ed operazioni essenziali

Gli esempi sono riportati insrc/walkthroughs

Facciamo una panoramica del set minimo di strumenti di cui abbiamo bisogno. Presenteremo:

  • 3 Data Type
  • 5 operazioni

Che cos'è un Data Type infp-ts?

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.

Data Type:Option

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.

Operazione:from*

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";

Operazione:is*

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 introducefp-ts all'interno di una procedura scritta senza
  • nei test, dove si predilige immediatezza ed espressività rispetto alla solidità della procedura

Operazione:map

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));

Operazione:chain

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));

Operazione:fold

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?)

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 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}))));

Data Type:TaskEither

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)});

Sommario

Data TypeSi usa per
OptionUn valore che c'è o è null-ish
EitherValidazione, operazione che può fallire
TaskEitherOperazioneasincrona che può fallire
OperazioneSi 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
mapApplicare una trasformazione al valore contenuto senza cambiare il sotto-tipo
chainApplicare una trasformazione al valore contenuto cambiando il sotto-tipo
foldFar convergere i due rami della computazione

Un caso reale: costruiamo un http endpoint

Scenario

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

Flowchart

Il flowchart che definisce l'endpoint

Codice

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)();

Conclusioni e Q&A

Vedi slides

About

Support material for a workshop at Codemotion Workshop Fest 2022

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors2

  •  
  •  

[8]ページ先頭

©2009-2025 Movatter.jp