Ondra Bašista
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.
Pokud budu postupovat po směru request flow, můžeme se bavit o následujícím:
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)), );
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), ); };
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), ); },
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.
Just leave us a message using the contact form or contact our sales department directly. We will arrange a meeting and discuss your business needs. Together we will discuss the options and propose the most suitable solution.