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