The art of technology
Blog
Back

FP-TS - TaskEither

Ondra Bašista
Ondra Bašista
9/30/2020
Technologie
FP-TS - TaskEither

V předchozích článcích seriálu jsem psal o dvou obecně nejznámějších monádách - Either a Option. Nejednoho bystrozora při čtení jistě napadlo - a je to logicky častý use case - zda existují i "monadic" varianty pro asynchronní programování? Odpověď je ano, byť nestačí do monády poslat Promise, dvakrát otčenáš a čekat na data.

Jakákoliv struktura, o které si budeme povídat, stále podléhá standardnímu chování TS/JS u asynchronního kódu - jakmile je asychronní funkce inicializována, je její běh vyjmut z běžného toku programu a lze si jej představit jako v imaginární větvi, oddělené od synchronního světa zbytku kódu. Tento fakt FP-TS žádným hackem neobchází, ale dává programátorům do rukou struktury, které umí podobné funkce/objekty řetězit a mapovat.

Pro ilustraci:

const asyncResult = async() => { return await 'toto je '; }

Pokud provoláme funkci asyncResult, rozhodně nedostaneme výsledek, ale opět Promise.

const anotherAsyncResult = asyncResult().then(preRes => preRes + 'výsledek').then(res => { console.log(res) });

Pokud chceme získat result, musíme se zkrátka také namočit, případně resolve provést ze scope jiné asynchronní entity.

Zda využiji promise jako takovou nebo async/await (není to JEN syntax sugar, ale kombinace promise a generátorů), na problému asynchronní větve nic nemění.

Nicméně, máme za sebou nutný úvod k tomu, abychom za async kódem v FP-TS nehledali nějakou novou magii, v zásadě se jedná o struktury, v tomto případě monády, schopné pracovat s klasickou promise v TypeScriptu. Tak vzhůru na ně.

Task

První monádou budiž struktura jménem Task, která reprezentuje asynchronní komputaci, u které se neočekává selhání. Její typová anotace je jednoduchá, v zasadě se jedná o lazy Promise. Typ tedy vypadá takto:

interface Task <A> { (): Promise <A> }

Task interface je tedy Promise schovaná za funkcí, což už na první pohled dává tušit, že pro získání value není potřeba speciální aparatury ala Either/Maybe. Stačí zkrátka zavolat funkci a použít například await.

Zároveň je ale struktura(implementace) Task monádou - je tedy opět poskládán z klasického API, které extenduje apply(applicative), který zase extenduje funktor atd. Má tedy k dispozici standardní "monadic API" ala map, chain.. Viz první článek seriálu, případně úplný úvod.

Samotným Taskem se prakticky zabývat nebudu a přejdu rovnou ke komplexnější struktuře - TaskEither.

TaskEither

TaskEither je narozdíl od Task komputace, u které lze očekávat záchyt chyby, a jak název napovídá, její interface vypadá a dokonce se přesně tak i chová - jako Either uvnitř Task, neboli Either uvnitř Promise, schovaný za funkcí.

interface TaskEither<E,A> extends Task<Either<E,A>>{}

Pokud tedy zavolám například await taskEither(), získám typ Either<E, A>. TaskEither je monádou, u jeho API tedy platí (obecně) to samé jako u jiných monád.

Pojďme si ukázat, jak s TaskEither pracovat na jednoduchých příkladech.

Nejprve si připravíme důvěrně známý kód:

interface Data { data: { id: string; content: { body?: string; desc: string; }; }; } interface SomeError { msg: string; } const someAsyncFn = async (): Promise<Data[]> => { return [ { data: { id: '1', content: { body: 'some body content', desc: 'some desc', }, }, }, { data: { id: '2', content: { body: undefined, desc: 'some desc', }, }, }, ]; };

SomeAsyncFn tedy vrací Promise a v ní pole typu Data. A nyní pro obalení takovéto funkce - stejně jako v případe Either - využijeme build in funkci TaskEither, tryCatch, která asynchronní funkci obalí do TaskEither monády:

const getSomeAsyncData = TE.tryCatch<SomeError, Data[]>( () => someAsyncFn(), (e) => ({ msg: JSON.stringify(e), }), );

Levá kolejnice tedy (v typu) znázorňuje chybový scénář, pravá data.

Nyní naše data můžeme začít v nějaké pipe řetězit. Připravíme si obyčejnou funkci, u které fail neočekáváme (leda by předchozí TaskEither skončil v levé větvi):

const addSomeNewData = (toAdd: Data) => (data: Data[]) => { return [...data, toAdd]; }

A obě funkce vložíme do pipe:

const result = pipe( getSomeAsyncData, TE.map( addSomeNewData({ data: { id: '3', content: { desc: 'some another desc', }, }, }), ), );

Návratová hodnota result pipe má nyní typ TE.TaskEither<SomeError, Data[]>. TE.map příjmul TE.TaskEither<SomeError, Data[]>, rozbalil value (vzal TaskEither a z funkce vracející Promise vytáhl pomocí then (resolve větev Promise) Either<SomeError, Data[]>), checknul zda není Either v levé kolejnici, a pokud ne - na datech zavolal funkci addSomeNewData a výsledek obalil znovu do Either. Proto tedy náš result vrací typ TE.TaskEither<SomeError, Data[]>. Either za () => Promise.

OK. Co ale dělat v případě, že by naší funkci getSomeAsyncData předcházela nějaký neasynchronní varianta monády, například obyčejný Either. I na to má FP-TS udělátko.

Vyrobíme si tedy nejprve funkcí vracející Either, kterou předsuneme před všechny ostatní v pipe:

const beforeAllFunction = (number: 1 | 2): E.Either <SomeError,number> => { if (number === 1) { return E.right(number); } else return E.left({ msg: 'number is two, error!' }) }

A nyní jí použijeme v pipe a jako přechod mezi Either a TaskEither využijeme opět API TaskEither, konkrétne funkci fromEither, která vezme Either, prověří zdá se nachází ve své left nebo right větvi, pokud v left - vyrobí TaskEither a do jeho levé větve obalí původní chybu, pokud v right - posune do pravé větve novou value.

const result = pipe( TE.fromEither(beforeAllFunction(1)), TE.chain(getSomeAsyncData), TE.map( addSomeNewData({ data: { id: '3', content: { desc: 'some another desc', }, }, }), ), );

Dobrá a nyní poslední varianta. Co kdybych z nějakého důvodu k datům uvnitř TaskEither (pravá větev) potřeboval uvnitř pipe přistoupit a nadále s nima pracovat například v klasické Option monádě? Jak jsem v úvodu článku deklaroval, TaskEither je jako návratová hodnota jen Either obalený funkcí a Promise - jednoduše tedy stačí pomocí then přejít do resolve větve Promise a s value pracovat jako se synchronní verzí Either.

Druhou a zřejmě komfortnější variantou je s daty manipulovat přímo uvnitř chain funkce:

const result = pipe( TE.fromEither(beforeAllFunction(1)), TE.chain(getSomeAsyncData), TE.map( addSomeNewData({ data: { id: '3', content: { desc: 'some another desc', }, }, }), ), TE.chain( flow( O.fromNullable, TE.fromOption(() => ({ msg: 'from option error', })), ), ), );

Pro převzetí payloadu uvnitř posledního chain jsem využil flow, což je alternativa k pipe, která ale využívá partial application. Pro jistotu uvede jednoduchý příklad.

const flowExample = flow((string:string) => `${string} appendix`)('string');

Funkce vrátí value "string appendix".

Opět, jako vždy dodávám, že je API pro asynchronní programování FP-TS komplexnější, existují i další async varianty jako TaskOption nebo TaskEitherReader. Zásadní je ale ukázka FP-TS pojetí asynchronních struktur JS/TS. Žádná magie, naše známé API (monáda, applicative, funktor..) složené do podoby schopné obsluhovat Promise.

V dalším díle se konečně podíváme na různé nástroje pro efektivní řetězení, skládání, shlukování a iterace takovýchto struktur. I () => Promise<Maybe<NextTime>>.