The art of technology

Blog

Back

FP-TS - TaskEither

Technology
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>>.