Ondra Bašista
V minulém díle jsem se věnoval problému error handlingu za pomocí Either monády. Jestli je v TypeScriptu (platí většinou i obecně napříč jazyky) nějaký další vývojářský perpetuum mobile, pak je to věčné ošetřování null/undefined values a návazná problematika - jejich řetězení, zanořování, typová (ne)deklarace.
V čem je problém?
Nyní k praktické ukázce. Nejprve tradiční přístup, poté přístup FP-TS.
interface Client { name: string; data?: { id: number; title?: string; }[]; } interface ErrorType1 { msg: string; type: string; } const errorType1 = (msg: string, type: string) => ({ msg, type, }); interface ErrorType2 { id: number; msg: string; } const errorType2 = (id: number, msg: string) => ({ msg, }); const initClient = () => { const client = { name: 'someClient', data: [ { id: 1, title: 'data1', }, { id: 2, title: undefined, }, ], }; return client as Client | undefined; // fejkujeme nespolehlivého clienta pro účel článku }; const printClientName = (client?: Client) => { if (client) { console.log(client.name); return client; } else { throw errorType1('client not found', 'COMMON'); } }; const printFirstDataTitle = ( clientData: { id: number; title?: string; }[], ) => { if (clientData && clientData.length > 0) { const clientDataTitle = clientData[0].title; if (clientDataTitle) { console.log(clientDataTitle); return clientDataTitle; } } else { throw errorType2(1, 'clientData error'); } }; const resultPipe = pipe(initClient(), printClientName, (client) => client.data, printFirstDataTitle);
Kód by se samozřejmě dal strukturovat lépe, ale na druhou stranu poměrně věrně zobrazuje několik neduhů. V několika bodech by bylo snadné vyrobit chybu s chybějícím null checkem, handling erorru není vůbec vidět v typové anotaci/inference a celé je to navíc při pohledu na pipe nic neříkající o nebezpečích, které funkce mohou ukrývat.
Nyní zkusíme variantu FP-TS a jednu z profláklejších monád - Option, občas zvanou jako Maybe. A protože jsme si v minulém článku ukázali jak pracovat s Either monádou, využijeme rovnou jejich kombinaci.
const initClient = (): O.Option<Client> => { const client = { name: 'someClient', data: [ { id: 1, title: 'data1', }, { id: 2, title: undefined, }, ], }; return O.fromNullable(client); };
Jako první jsem si pomocí fromNullable vytvořil z potenciálně undefined/null value nový typ - Option monádu: typová anotace vypadá jako O.Option <Client> a v debugu by se struktura jevila jako {_tag: "Some", value: Object}, případně jako {_tag: "None"}.
A v {_tag: "None"} je právě ten fígl. Zaprvé - v Option monádě nadále nepracujeme s undefined nebo null value, ale speciálním objektem otagovaným jako None, přičemž jakýkoliv další map/chain volaný nad takovýmto objektem zkontroluje zda není otagovám jako None a rovnou posílá výsledek do kolejnice pro empty value. Podobně jako Either pracuje s Left, pracuje i Maybe s tagem None.
Jako další si vydefinujeme naší funkci pro získání property name z klienta, ale naschvál ji ponecháme bez ošetření, abychom si ukázali jak takovou funkci obalíme Maybe monádou "zvenčí".
const printClientName = (client: Client) => { console.log(client.name); return client; };
OK. Vytvoříme si první kousky pipe z našich dvou funkcí. Pro přehled deklaruji typ předem.
const result: O.Option<Client> = pipe(initClient(), O.map(printClientName));
Výsledkem je tedy entita, která v prvním kroku ve funkci initClient vrátí Option monádu - value nebo None. Následně použijeme funkci map, která jako první argument příjme funkci printClientName a jako druhý Option monádu (návratová hodnota initClient). Interně prověří zda je příchozí monáda None, a pokud ano, jede po prázdné kolejničce a funkci printClientName nikdy nevykoná.
Popojedeme dál. Nyní si ukážeme jak propojit dvě monády přechodem mezi Option a Either. Pokud Option vrátí None, Either aktivuje svou levou větev a od programátora očekává chybovou událost.
const result: E.Either<ErrorType1, Client> = pipe( initClient(), O.map(printClientName), E.fromOption(() => errorType1('client not found', 'COMMON')), );
Typ se nám změnil na E.Either<ErrorType1,Client>. Jasně deklarujeme, že máme co dočinění s možnou error value už při čtení pipe, bez nutnosti číst implementace funkcí.
Nyní přepíšeme naší poslední funkci, která musí nakouknout do prvního prvku pole (pokud takový existuje), najít title a ten vytisknout. V případě že title z nějakého důvodu nenajde, musí vrátit chybu a transformovat se v Either.
const printFirstDataTitle: E.Either<ErrorType2, string> = ( clientData: { id: number; title?: string; }[], ) => pipe( O.fromNullable(clientData), O.chain((data) => A.lookup(0, data)), O.mapNullable((item) => item.title), O.map((title) => { console.log(title); return title; }), E.fromOption(() => errorType2(1, 'clientData error')), );
Chain nebo flatmap sme si vysvětlovali v minulém díle, ale pro jistotu zopakuji - chain vezme data z monády (O.fromNullable(clientData)) a aplikuje na ně funkci, která také vrací monádu (funkce A.lookup vrací Option< itemZPole >) a narozdíl od map už výsledek znovu nebalí sama do sebe - nevzniká tak Option uvnitř Option ala Option<Option< string>>.
Pojďme sestavit naší závěrečnou pipe.
const result = pipe( initClient(), O.mapNullable(printClientName), E.fromOption(() => errorType1('client not found', 'COMMON')), E.map((client) => client.data), E.chain(printFirstDataTitle), // printFirstDataTitle vrací Either<ErrorType2, string>, );
Bác. Něco ošklivého se přihodilo. IDE vrátilo chybové hlášení: Type 'Left< ErrorType1 >' is not assignable to type 'Left< ErrorType2 >'.
Co se právě stalo? Jedná se o neduh v chování FP-TS, který ve starších verzích knihovny poměrně komplikoval vývoj a nutil programátora k různým union typům, mapováním levé kolejnice Either a dalším hackům. FP-TS totiž, při použití standardních variant map/chain, vyžaduje stejný typ chyby v dané flow. Naštěstí existuje řešení jménem W neboli widen/unionise.
Pokud totiž namísto map/chain použijeme varianty mapW/chainW, vidíme již IDE zelené a naprosto správnou typovou notaci. Tedy E.Either<ErrorType1 | ErrorType2, string>. Funkce printFirstDataTitle vrací jiný typ chyby (left) než errorType1 a naše pipe stav nyní správně reflektuje.
Výsledná funkce vypadá nyní takto:
const result: E.Either<ErrorType1 | ErrorType2, string> = pipe( initClient(), O.mapNullable(printClientName), E.fromOption(() => errorType1('client not found', 'COMMON')), E.map((client) => client.data), E.chainW(printFirstDataTitle), );
K praktické ukázce dodám, že záměrně jednou používám Either/Maybe uvnitř řetězené funkce (printFirstDataTitle) a jednou ve scope řetězící pipe. Ať už je Either/Maybe/whatever struktura použita kdekoliv - například právě uvnitř jedné z řetězených funkcí - programátor nakonec musí zajet do kolejniček použitím mapování, čímž i při pohledu zvenčí (bez detailnější znalosti struktury funkcí), jasně deklaruje práci s potenciálně nullable, chybovým, jakýmkoliv stavem.
Do USB-C prostě kabel od sekačky nezapojíte. A každý jouda (funkce) se nemusí pokoušet rozbitou sekačkou zušlechťovat trávník. Stačí na ní jednou nalepit ceduli. Railway orintented programming v praxi.
Článek máme za sebou. Tentokrát jsme se podívali na Maybe monádu v kombinací s Either monádou. Pokud by si měl čtenář něco odnést, pak je to dle mého myšlenka kolem deklarativního handlingu a především existence relativně jednotného rozhraní pro odlišné struktury. Právě kanonický (obecně přijímaný) přístup k handlingu a velmi popisné typy, mohou významně omezit chybovost a typové nepřesnosti (či přímo lži) v kódu.
FP-TS ekosystém navíc nabízí spoustu nástrojů, které jsou s Either a Maybe v symbióze. Existuje knihovna monocle-ts (fp optics, lenses) pro kompozici a null handling nested objektů. Skvělá je knihovna io-ts, která produkuje Either jako výsledek runtime type checku. Obě monády navíc existují ve svých asynchronních variantách.
To byla malá ochutnávka, a právě asynchronnímu programování se budeme věnovat v příštím díle.
Takže FP zdar!
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.