The art of technology

Blog

Back

Funkcionální TS aplikace 1 - skořápka

Technology
Funkcionální TS aplikace 1 - skořápka

V předešlých měsících jsem se jal produkce seriálu o funkcionálním programování v TypeScriptu, zejména pak adaptaci knihovny fp-ts. Musím přiznat, že jsem sám sebe unavil - postupem času mi připadalo, že přestávám řešit reálné problémy a příliš času trávím hrátkami se syntaxí. Hlubiny fp-ts jsou (zejména při pokusech o nějaký akurátní funkcionální přístup) bezednou studnicí dvacetiřádkových funkcí, které přináší chvění v intimních partiích spíše autorům kódu, než jeho čtenářům, natož pak skutečný přínos. Odběhl jsem si tedy na pár měsíců k duševní očistě ve formě strohé syntaxe jazyka Go (moc pěkný zážitek btw), abych se přeci jen i ve své hobby "kariéře" vrátil ke starému dobrému Node.js.

Jak už to ale na světě chodí, člověk často nakonec skončí ve stejné louži. Chci si vyřešit error handling? Nic lepšího než either/result nevymyslím. Neustále null values checky? Nová TS syntax pomáhá, ale stále to není ono. Kompozice funkcí? Popisnější kód? Když jsou delfíni tak chytrý, tak proč žijou v iglu? Nevím.Takže zase zpátky do bačkůrek. A protože se mi už články nechce kouskovat na jednotlivé technologické segmenty, pokusím se seriálově vydávat vývoj menší backendové aplikace.

O co půjde? Žádná geniální myšlenka se v hodinových seancích, za tichého šplouchání vody v záchodové míse, nedostavila. Vyrábět budu aplikaci tvořící formátované databázové reporty či updaty na základě vstupu uživatelů a výsledky konání nějakým způsobem shlukovat a podávat v různých formátech (xlsx etc.). V práci se mi taková věc občas hodí a jako (samo)vyuková aplikace, proč ne. Jak by řekl klasik - lepší zabít orka jednou ranou, než hodinu honit goblina po stole.

Technologicky se bude jednak o stack ala: TS, Express/GraphQL, fp-ts, MongoDB a podobně. A první vrstva, kterou hodlám naprogramovat a rozebrat bude vstup do aplikace, chcete-li víc cool - application boundaries, shell, skořápka.

Nutno dodat - nejedná se o step by step návod, ani referenční příručku.

Application boundaries a io-ts

Začnu buzzwordem - application boundaries. V kontextu naší aplikace se bude jednat o veškeré vstupy třetích stran - rozuměje tedy: handlery pro Express či Graphql, vzdálená API, potažmo databáze. Zkrátka, tenká červená linie vystupuje tam, kde končí kontrola nad povahou dat a tam, kde je potřeba pro vstup struktur do aplikační logiky nějaké ověření, validace, otypování.

Protože surová data vstoupí do ekosystému TS, hodilo by se hned několik operací:

  • data strukturálně ověřit
  • data vsadit do business typů systému
  • kontrolovaně handlovat chyby, které mohou při parsování vzniknout

Pro strukturální validace existuje spousta knihoven / případně nám jistý kontrakt zaručuje samo GraphQL. Vsadit data do business typů pomocí nějakých type guardů by také nebyl až takový problém. Handlovat chyby jakbysmet. Pro ekosystém kolem fp-ts ale existuje skvělá all-in-one varianta - knihovna io-ts, která přidává i něco navíc a především, perfektně se zbytkem rodiny lícuje.

Začneme zlehka a počítejme s nastartovaným Express serverem. První endpoint, který vytvoříme, budiž jednoduchý post, který přijímá jednoduché body:

{
    "collectionName": string,
    "limit": number,
}
expressServer.post('/mongoQueryReport', (req, res) => {
    console.log(req.body)
});

Ve výsledné aplikaci se bude jednat o endpoint složitější, ale řekněme, že na základě názvu kolekce a limitu, provede operaci generující nějakou formu reportu.

Jako další krok, bude tedy následovat modelování "domény". Data v body skrze handler vstoupí do aplikace, a v tu chvíli je potřebujeme již řádně otypována, očištěná a pěkně zabalená.

A právě při modelování narazíme na "istá kulturná špecifika". Začněme tedy názvem kolekce, pro kterou si nejprve připravíme obecnější strukturu. Nenulový string s délkou maximálně 50 znaků.

export interface _nonEmptyString50 {
    readonly NonEmptyString50: unique symbol;
}

export const NonEmptyString50Branded = t.brand(
    t.string,
    (s: string): s is t.Branded<string, _nonEmptyString50> => s.length > 0 && s.length <= 50,
    'NonEmptyString50',
);

Pokud si strukturu rozebere, zjistíme že jsme vytvořili codec pro tzv. branded type.

  • Jedná se o strukturální validaci, kdy si vydefinujeme pravidla, dle kterých typ potvrdíme/případně validaci pošleme do chybové větve. Zde tedy string mezi 1 a 50 charaktery.
  • Pomocí symbolu přidělíme výslednému typu unikátní flag (složitější typ složený z primitivního typu a symbolu, pro představu např. t.brandC<t.NumberC, _nonEmptyString50>)

Vsuvka 1 - symbol

Symbol je primitivní datový typ (sám seš primitivní!), stejně jako number nebo string. Má vždy unikátní hodnotu a je immutable. Teoretické je pak možné pomocí něj tvořit property s unikátním názvem nebo typem. Co je navíc při tvorbě podobných mašinek jako branded typy užitečné, je automatický omit takové property při serializaci, takže se například při HTTP getu nemusíme obávat podivných propert v resultech.

Rychlá ukázka:

Symbol('foo') === Symbol('foo') // false
expressServer.get('/symbol', (req, res) => {
    const uniqueId: unique symbol = Symbol('uniqueId')

    type TypeWithSymbol = {
      username: string;
      [uniqueId]: typeof uniqueId;
    }
    const objWithSymbol: TypeWithSymbol = {
      username: 'username',
      [uniqueId]: uniqueId,
    }

    res.send(objWithSymbol)
});

  // result -  { "username": "username"}

Proč ale vynalézat kolo.

Zatím generický NonEmptyString50 využijeme jen jako základ konkrétnější validace. K polidštěné hlášce si pomůžeme kombinátorem withMessage z knihovny io-ts-types.

const CollectionName = withMessage(
    NonEmptyString50Branded,
    (input) => `Collection name value must be a string with size between 1 - 50 chars, got: ${input}`,
);

Samozřejmě, jestli stačí collectionName označkovat jako (ne)prazdný string či jestli jako branded označíme přímo business implementace samotné, záleží na použití.

OK. Máme tedy business codec pro propertu collectionName. Zopakujeme i pro propertu limit a dotvoříme interface.

const Limit = withMessage(
    NonZeroNumber20Branded,
    (input) => `Limit must be a number between 1 - 20, got: ${input}`,
);
export const CreateMongoQuery = t.strict({
    collectionName: CollectionName,
    limit: Limit,
});

Zaintegrujeme ho do našeho post volání a decodujeme obsah request body.

expressServer.post('/mongoQueryReport', (req, res) => pipe(
    req.body,
    CreateMongoQueryBranded.decode,
)

A protože jsme víc hipsta, nežli jednadvacetiletý kříženec Harryho Pottera a císaře Ferdinanda, poletující po Karlíně na elektrokoloběžce, nemůžeme jako výsledek očekávat exception. Jak už bývá u fp-ts zvykem, výsledkem budiž Either<Errors, DosadBrandedTyp>.

 Either<Errors, DosadBrandedTyp>

Vsuvka 2: Either monáda

V některých jazycích můžeme zahlédnout jako strukturu jménem Result. Více v samostatném článku. Zjednodušeně - struktura, která rozhoduje o vykonání další operace na základě kontextu a operuje s daty ve dvou větvích. Jedna může být použita například pro chybovou cestu, druhá pro tu OK. Vzdáleně si představme jako js promise, která může držet hodnotu jak ve větvi resolve, tak ve větvi reject.

type Either<E, A> = Left<E> | Right<A>

Dobrá, máme čím validovat, máme výsledek připravený na cestu systémem. Co nám chybí, je definice typu pro návrat do statického světa červených vlnovek editorů. I ten ale můžeme pomocí io-ts helperu odvodit.

type  CreateMongoQuery = t.TypeOf<typeof CreateMongoQueryBranded>;

Statický typ odvozený z branded typu má, jak už název napovídá, jednu zajímavou vlastnost (viz. odstavec o symbolech). Duck typing praví, že pokud něco jako kachna vypadá, stejně se to kolébá, pak to musí být kachna. Ne tak s branded typy.

type A  = {
  p1: number,
  p2: string
}

type B  = {
  p1: number,
  p2: string
}


const fn = (input: A) => input;

fn(a)  //ok
fn(b) // ok

Pokud bychom však použili dva statické typy odvozené ze dvou branded definic, dočkáme se statické chyby, ačkoliv se jedná, z pohledu primitivních typů, o dva řetězce.

type A = t.TypeOf<typeof NonEmptyString20Branded>
type B = t.TypeOf<typeof NonEmptyString50Branded>

const fn = (input:A) => input;

fn({} as unknown as A)  // ok
fn({} as unknown as B) // error

O užitečnosti můžeme polemizovat. Nicméně, zaplatit 10 000 euro nebo 10 000 bolívarů, není to samé, jako uložit _id uživatele namísto _id rychlovarné konvice.

Text se nám začíná protahovat, je na čase první část ukončit.

expressServer.post('/mongoQueryReport', (req, res) => pipe(
    req.body,
    CreateMongoQueryBranded.decode,
   // placeholder pro vstup do core aplikace
    E.fold(e => res.send(e), decoded => res.send(decoded))
))

Na příchozí strukturu ve formátu Either<Errors, A> aplikujeme fold - tedy vyloupneme data z chybové i ok větve a nijak nezpracované je odešleme do response. Pokud na endpoint odešleme chybná data, získáme ošklivou, neučesanou chybu.

[{"value":4,"context":[{"key":"","type":{"name":"{| collectionName: NonEmptyString, limit: NonZeroNumber |}","type":{"name":"{ collectionName: NonEmptyString, limit: NonZeroNumber }","props":{"collectionName":{"name":"NonEmptyString" .....

Dostali jsme tedy do bodu, kdy máme připravený hloupý HTTP handler, nadefinovaný codec a opravdu hnusnou chybovou hlášku. So far so good :-D

Do pořádné aplikace daleko. Jestli má mít ale úvodní článek (doufejme že není poslední) nějaké poselství, pak utvořit představu jakési hranice, kde označkováním surových dat vznikají zvalidované, unikátní business typy, připravené pro manipulaci soukolím fp-ts.

V dalším díle bych rád méně okecával, připravil rozsáhlejší GitHub snippety, nastavil error handling a na vše připravil HTTP handlery.

Připojuji předchozí díly ApiTree série a užitečné odkazy. Loučím se.

Jo, a řekněte to tetě!