The art of technology

Blog

Back

FP-TS - stojí to za to?

Technology
FP-TS - stojí to za to?

Zdravím u opožděného (ale přeci!) pokračování ApiTree seriálu o funkcionálním programování v TypeScriptu. Tentokrát bych se, ač mám stále rozepsaný blog o komplexnějším skládání HKT, rád trochu rozepsal o ekosystému kolem FP-TS, vzal to trochu kolem dokola a nakonec se pokusím najít odpověď na otázku života, vesmíru a vůbec. Jo a řekněte o tom tetě.

A rovnou začnu obrázkem takové, řekněme standardní mini Node JS aplikace (tentokrát budeme na backendu), která má nějaké HTTP api, nějaký core s business logikou, komunikuje s databází a API třetí strany. Táta FP-TS si totiž stihnul nadělat děcka a tihle otroci dokáží obsloužit a nebo alespoň přisluhovat na téměř každé vrstvě systému.

nodejsfpts

 

Pokud budu postupovat po směru request flow, můžeme se bavit o následujícím:

  • HTTP handlery - hyper-ts - Jeden z méně profláknutých členů rodiny, nicméně užitečný v případě, že hodláte v poněkud hardcore formě (ve smyslu ve vztahu k idiomatickému http v Node) napojit svojí logiku na http handlery. Hyper-tss decodem JSONu v Either formátu kontroluje zda nezapisujeme do odeslaných responses, zavřené/otevřené hlavičky etc. Pokud se nic nezměnilo zasloužil by update v podobě řetězení chybových typů (chainW..), ale flow middlewarů dokáže pohlídat zkušeně.

typescript: hyper-ts handler

const getIssuesHandler = pipe(
    H.decodeParams(emptyObject.decode),
    H.mapLeft((e) => createBackendError(failure(e).join('\n'), ErrorTag.api)(e)),
    H.ichain((_) => H.fromTaskEither(getIssues())),
    H.ichain(sendBodyAndClose('getIssuesHandler JSON error')),
    H.orElse(withErrorCode(H.Status.BadRequest)),
);

 

  • validace - Tady je kandidátů několik, nicméně FP-TS ekosystém nese build-in validační funkce a pomocné "lift funkce" (typicky, výsledkem validace může být několik Either typů, které je potřeba spojit/concatnout/traversnout), dále je tu io-ts pro tvorbu custom decoderů, branded typů, jak pro strukturální, tak pro custom validace. Pomoci může i newtype-ts, pokud by typů bylo málo.

typescript: validace pomocí Either a Option

const validateName = (input: RegisterAccountInput): E.Either<string[], RegisterAccountInput> =>
    pipe(
        input,
        O.fromNullable,
        O.mapNullable((inp) => inp.name),
        E.fromOption(() => ['missing name']),
        E.chain((title) => (title.length > 30 ? E.left(['maximum 30 characters']) : E.right(input))),
    );

typescript: concat několika Either validačních výsledků

const validateRegisterAccountInput = (input: RegisterAccountInput): E.Either<string[], RegisterAccountInput> => {
    return pipe(
        sequenceT(eitherLeftConcat(getSemigroupArray<string>()))(validateName(input), validateSurname(input)),
        E.map(() => input),
    );
};

 

  • services - Zde se divoké fantazii meze vůbec nekladou, nicméně kouzlo tkví právě v propojení jednotlivých komponent aplikace skrze kolejnice FP-TS. Z HTTP handleru vyleze dekodovaný Either<Chyba, Input>, z validací vyleze Either<Chyba, Input>, vstoupí do service a řetěz zůstává nepřerušen. Občasný přeskok na kolejnici jiného typu (například z Option na Either, ze synchronní na asynchronní) vůbec nevadí a FP-TS s ním počítá.

typescript: high level pohled na registrační funkci

   register: (request: Request) => {
        log.info(`register: request ${request}`)();
        return pipe(
            request.body,
            registerAccountRequest.decode,
            E.mapLeft(fromIOErrors),
            TE.fromEither,
            TE.chain(checkAccountExists),
            TE.map(preparePlainAccountDocument),
            TE.chain(createAccountDocument),
        );
    },

 

  • database a 3rd party API - Opět ta samá písnička. Backend dotaz pošle do obálky (např. TaskEither) a response/chyba zajede do kolejnic.

typescript: TaskEither mongo connect

export const getConnectedInternalClient = (uri: InternalMongoUri, options?: InternalMongoClientOptions): TE.TaskEither<BackendError, InternalMongoClient> =>
    pipe(
        TE.tryCatch(
            async () =>
                MongoClient.connect(
                    isoInternalMongoUri.unwrap(uri),
                    isoInternalMongoClientOptions.unwrap(options),
                ),
            createBackendError(`Get internal database client error.`, ErrorTag.database),
        ),
        TE.map(isoInternalMongoClient.wrap),

typescript: newtype-ts aneb když je string málo

export interface InternalMongoUri extends Newtype<{readonly InternalMongoUri: unique symbol}, string> {}

export const isoInternalMongoUri = iso<InternalMongoUri>();

 
 

A POINTA?

Dalo by se pokračovat donekonečna. Pro skládání složitějších objektů či různé lookupy/lenses máme monocle-ts, existuje repositář fp-ts-contrib, obsahující další funkcionální lahůdky (např. Do notation či další typy monád, díky kterým budete mít značný hipsterský level a právo jezdit po Karlíně na puntíkaté koloběžce s knírem ale císař Franz), tudíž se lze dopracovat až do stádia, které ten běžný TypeScript téměř nepřipomíná. A právě sem míří má pointa. Stojí vůbec za to, psát v TS na tak vysoké úrovni abstrakce, kód pramálo idiomatický a vrhnout se do náruče jednoho italského matematika? Zde nabízím několik osobních PRO i PROTI.

 

PRO

FP-TS je navoněná branka do světa funkcionálního programování. Ano, byly tu knihovny typu Lodash či Ramda, ty ale podávaly spíše pomocnou ruku v podobě deklarativních udělátek typu R.head či _.uniqueBy etc. FP-TS ale přináší celý komplexní systém známý z jiných jazyků a samotného mě po hrátkách s jazykem F# (functional first .NET) překvapilo, jak je FP-TS relevantní. A nejedná se jen o práci s typy jako Option nebo Either (které implementují i některé multiparadigmatické jazyky, např. Rust), ale prakticky totožné chování implementuje FP-TS u spousty různých funkcí nutných ke skládání/řetězení/iteraci podobně komplexních typů. FP-TS je solidní základ pro pochopeni algebraických typů, HKT a jejich kompozici. Viz. úvod.

F#:

 let resultFn s = match s with
                     | "error" | "error2" -> Error "error msg"
                     | _ -> Ok "ok"
    
    let short = results |> List.traverseResultA resultFn
    

typescript:

pipe(
    input.accountIds,
    findManyDocumentsByIds(accountModel),
    TE.chain((accountDocuments) => array.traverse(TE.taskEither)(accountDocuments, activateOrDeactivateAccount(SystemStatusEnum.INACTIVE))),
);

 

PRO

Jako další velké pro vidím jednoznačně rozšíření zakrnělých obzorů. Funkcionální jazyky mají obecně delší křivku učení (?) a některé principy (právě třeba monády) jsou na první pohled těžko okoukatelné, na rozdíl od více "low level" C like syntaxe. Takže možnost pokoušet funkcionální uvažování v jazyce který důvěrně znám, budiž kvitována s povděkem.

 

PRO

FP-TS má momentálně 6k github stars a na webu koluje spousta tutoriálů, seriálů, stackoverflow a dalšího materiálu. Zdaleka se už nejedná o ten "hipsta bizár" jako nějaký ten měsíc/rok zpět a člověk se necítí jako kráva do hadí jamy vhozená. Za rok a půl jsem navíc nezaznamenal vážnější problém se zpětnou kompatibilitou, FP-TS se spíše rozšiřuje, než by měnilo směr.

 

PRO i PROTI

FP-TS není nutno akceptovat naprosto komplexně, byť k tomuto častému argumentu mám výhrady. Ano, je možné použit Either pro error handling a Option pro nahrazení null a undefined. Nicméně, může se stát, že do FP strčíte prst a následně vám ukousne nasliněnou ruku. Přirovnal bych to k rozhodnutí použit Promise. Hezké, ale co když takový typ potřebuji řetězit? Napojit na jiný typ? Použit paralelně? Rozbalit? Zabalit? Rozbalit, použít a zabalit? Takže ano, lze aplikovat cherry pick, ale při komplexnějších úlohách je potřeba se namočit.

 

PROTI

Tohle sakra není TypeScript! Vypadá to jako Haskell pro méně nadaná děcka! A je to naprosto relevantní argument. Pro firmy je problém nabírat nováčky a nakouknutí vyjukaného juniora do soukolí neživých monadických koles, může způsobit pocity na zvracení, v horším případě snad otoky sliznic a změnu orientace (programátorské).

typescript: (nebo něco takového)

const eitherLeftConcat = < TError > (semi: Semigroup < TError[] > ): Monad2C < typeof E.URI,
    TError[] > => ({
        URI: E.URI,
        _E: undefined as any,
        map: E.either.map, // (either<TError,A>,fn(A => B) => Either<TError,B>
        of: E.either.of, // (A) => Either<TError, A>
        ap: (mab, ma) => (E.isLeft(mab) ? (E.isLeft(ma) ? E.left(semi.concat(mab.left, ma.left)) : mab) : E.isLeft(ma) ? ma : E.right(mab.right(ma.right))), // (either<TError, fn(A => B)>, either<TError,A> => Either<TError,B> - aka unpack Either<TError, fn(A => B)> to Either<TError, B> aka put A right to fn(A) and wrap
        chain: E.either.chain,
    });

const getSemigroupArray = < A > (): Semigroup < A[] > => ({
    concat: (x, y) => [...x, ...y],
});

 

PROTI

TypeScript není v první řadě funkcionální jazyk a ne každý TS programátor podobné přístupy reflektuje. Ač se (zdá se mi vpádem Reactu) JS svět točí spíše směrem od "ala OOP", stále vídám spoustu kódu připomínající spíše nějaké prototypové objektové (důležité slovo objektové, MVC aplikuje např. i Elixir framework Phoenix, mvc není závisle na paradigmatu) DI MVC či jiné MXXX mutace ať už v podobě Angularu, NestJS či prostého zvyku kód podobně strukturovat (takové to "Náš framework je Spring pro .." a "this.SuperAbstractFrontendDirectorFactory" ). Funkcionální přístup se dá i přesto aplikovat na ledacos, nicméně i samotný TypeScript/JavaScript může být limitem.

Funkcionální jazyky mají syntax/expresiva totiž navržené tak, aby se dalo na vyšší úrovni abstrakce pohodlněji pracovat (logicky). Například mnou zmiňovaný F# disponuje pattern matchingem, díky kterému je právě rozbalování a porovnávání komplexnějších struktur jednodušší a přehlednější. F# má build in pipe ( |> ), nevyžaduje závorky, je immutable by default. Fish operátorem (>=>) lze zase struktury zrychleně řetězit a dalo by se pokračovat funkcionalita po funkcionalitě. Knihovny FP jazyků pak by default počítající s takovým kódem přirozeněji. A vizuálně kód působí jaké méně obalený obslužným balastem, ve kterém se může ztrácet business logika.

F#:

  let getIssue id dbClient httpContext =
        let issue = getIssueById dbClient id

        let res =
            match issue with
            | Some iss -> JsonConvert.SerializeObject iss
            | None -> "Issue not found"

        Successful.OK res httpContext

typescript:

const checkAccountExists: CheckAccountExists = (request: RegisterAccountRequest) => () =>
    findAccountByEmail(request.email)().then(
        flow(
            E.chain(
                flow(
                    O.fromNullable,
                    E.fromOption(() => toPortalErrors([`Account not found.`])),
                ),
            ),
            E.chain((res) => (res ? E.left(toPortalErrors([`Account registred with email ${request.email} allready exists.`])) : E.right(request))),
        ),
    );

 
 
A tady bych pro dnešek skončil. Zda strčit hlavu do králičí nory nechám na vás, nic ale není černobílé a to jsem chtěl po hype článcích mírně demonstrovat. Je velmi pravděpodobné, že za 5 let nebude polovina TS kódu psána pomocí FP-TS, stejně jako nebude F# skákat po C#, Scala nebude drtit Javu a Elixir nenahradí PHP. Jako první nakouknutí do toho "hardcore FP", je to ale počin přímo geniální. A navíc můžete machrovat v práci na balkóně u kafe. S ležérním pohledem třicátníka, který tam už byl..  

p.s. Autor článku pouze nakukuje do FP z pozice Node JS vývojáře a Haskell Curry je podle něj hráč NBA.