The art of technology
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ě.
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 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>>.