W sieci krąży mnóstwo postów i prezentacji opisujących różne typy wbudowane TypeScripta, a także, jak na ich podstawie zbudować dziesiątki innych typów. To zaś, jakie problemy chcemy dzięki nim rozwiązać, to zupełnie inny temat. Ważniejszy, bo determinuje, PO CO mielibyśmy w ogóle dane rozwiązanie zastosować. I na tym się w poniższym tekście skupimy. Zapraszam do lektury :)
TypeScript jest jedną z tych technologii, o których sporo się mówi od dobrych kilku lat, a w ostatnim czasie w szczególności. Używamy go głównie po to, aby błędy, które mogłyby wystąpić w trakcie działania aplikacji (runtime), wyłapać wcześniej, w kompilacji (compile-time). Dzięki temu na produkcji ląduje mniej błędów. Ale dodatkowo, jeżeli odpowiednio wcześnie weźmiemy to pod uwagę, będzie nam łatwiej refaktorować kod oraz poruszać się po projekcie, którego nie znamy. W efekcie łatwiej nam będzie go utrzymywać – i na tym się skupmy.
Wyobraźmy sobie, iż pracujemy nad systemem, w którym wykonywanych jest wiele transakcji płatniczych. Może system do fakturowania, może bank, może pożyczki – w każdym razie hasła takie jak „pieniądz”, „kwota”, „opłata” i im podobne pojawiają się bardzo często. Zarówno na poziomie API, jak i w warstwie wizualnej.
Dość wcześnie trzeba przyjąć jakąś reprezentację dla typu określającego pieniądze. Potrzebujemy go jakoś zamodelować. Pierwszy pomysł, jaki może nam przyjść do głowy – to siup! – number. Rozwiązania proste są o tyle wartościowe, iż mają niski próg wejścia i ludziom powszechnie łatwo je zrozumieć, dodatkowo zmniejszamy ryzyko tak zwanego overengineeringu, czyli przekomplikowania rozwiązania. Rozwiązania proste warto brać pod uwagę od samego początku, bo często są zwyczajnie good enough.
Budujemy więc nasz system w oparciu o pieniądze, które zamodelowaliśmy jakonumber. Tu amount: number, tam debt: number, discount: number. Po paru latach wystąpień number jest kilkaset, o ile nie więcej. Przychodzi do nas Product Owner i mówi, iż nasz produkt doskonale sobie radzi na rodzimym rynku, więc czas na podbój rynków zagranicznych. W związku z czym przyjęta implicite waluta (PLN) musi zostać określona explicite w każdej funkcjonalności. o ile na przykład sprzedajemy towary w Polsce, ale wysyłamy je do Czech – i Czesi chcą płacić w koronach czeskich – to siłą rzeczy musimy uwzględnić dwie waluty. To taka dość oczywista sprawa. „No to ile to będzie kosztować story pointów?” – pyta PO. Cisza trwa niepokojąco długo. „Przecież to jest dodanie jednego pola”. Cisza się przedłuża. „Na pewno w jednym sprincie nie zdążymy”. „Ok, a potraficie w przybliżeniu oszacować, jak duże jest to zadanie?”
Generalnie numbernie jest w stanie przechować informacji o walucie – i wszystkie miejsca, gdzie występuje, trzeba zaktualizować tak, aby była nie tylko kwota, ale i explicite waluta. Jest może kilkadziesiąt miejsc do zmiany, może kilkaset, a do tego jeszcze gros testów, w sumie ciężko to oszacować… Przeszukiwanie codebase po frazie number daje kiepskie wyniki, bo number, czyli liczba reprezentuje liczbę sztuk, jakie klient zamówił, albo ilość powtórzeń transakcji, albo kolejny numer na liście (na przykład pozycja na fakturze). I informacja, iż number występuje 1591 razy, kilka nam mówi.
Dodatkowo, gdybyśmy hipotetycznie namierzyli te wszystkie miejsca, gdzie występuje number, i dodali obok drugie pole currency to zaczęłaby się jazda, bo obok amount: number trzeba by dodatkowo obsługiwać currency. Wygodniej byłoby zareprezentować hajs jako jeden spójny byt, tzw. Value Object. Problem w tym, iż jest o te dwa lata za późno, wszędzie mamy number, bo za wczasu nie pomyślelismy o odpowiedniej abstrakcji. Nie zamodelowaliśmy naszej domeny.
Primitive Obsession
Sytuacja, w której się znaleźliśmy, powszechnie nazywana jest primitive obsession, czyli „znam typy prymitywne, nie bawię się w abstrakcje, bo po co, więc tłukę stringi i numbery”. Nie opakowuję tych danych w dodatkowe (nadmiarowe?) typy, bo przecież liczba to liczba. Prawda?
Takie podejście jest kłopotliwe z co najmniej dwóch powodów. Po pierwsze, jeżeli reprezentujemy hajsiwo jako number, to na hajsiwie możemy wykonywać te same operacje co na każdej liczbie. Hajs można oczywiście dodawać i odejmować. A czy można go mnożyć? 10 zł można pomnożyć przez 10. Ale czy 10 zł można pomnożyć przez 10 zł i mieć 100 zł^2? Mnożenie przez liczbę a mnożenie przez pieniądz to inne operacje. Jest to potencjalny błąd, który – odpowiednio skonfigurowany – system typów może nam łatwo wychwycić. Część z Was pomyśli: „no przecież ja takich błędów nie popełniam”. Widząc mnożenie pieniądza przez pieniądz pewnie gwałtownie wychwycimy, iż coś jest nie halo w kodziku. Natomiast jeżeli pracujemy na istniejącym, działającym produkcyjnie kodzie… i potrzebujemy zaaplikować zmianę, która dotknie wielu miejsc, przejrzenie dokładnie wszystkich miejsc, których zmiana dotyka, mogłoby zająć za dużo czasu. Ostatecznie zmieniamy więc kodzik i patrzymy, czy się kompiluje; i patrzymy, czy testy przechodzą. Mamy mieszane uczucia, nie ufamy tej zmianie – mimo iż po to są testy i kompilacja, aby im ufać. No bo ta zmiana taka krzywa, może być różnie. Chyba na czas rilisa wezmę wolne…
Drugi kłopot z primitive obsession to pozbawianie kodu znaczenia. jeżeli widzę funkcję process(value: number), to wiem tyle, iż to liczba. A w przypadku process(value: Money)wiem już trochę więcej. Odpowiednio długo czytając okoliczny kod, dojdziemy pewnie do tego, iż chodzi o pieniądze. Tylko czy nie lepiej sobie od razu ułatwić, skoro jakiś typ trzeba i tak wpisać?
Jakie zatem jest rozwiązanie? Pamiętajmy, żeby nie przeinżynierować. Spróbujmy od stworzenia zwykłego aliasu typu:
type Money = numberI w miejscach, gdzie definiujemy pieniądze, stosujemy od tej pory Money. To wszystko. Narzut na kod jest niemalże zerowy. Definiujemy jedno źródło prawdy (znane wszem wobec Single Source of Truth) i – chyba najważniejsze – nadajemy temu typowi nowe, osobne znaczenie. Osobne niż prymityw number.
Patrząc zaś od strony technicznej – co się zmieniło?
- jeśli będziemy chcieli zmienić typ reprezentujący pieniądz, zmieniamy jego 1 deklarację (bo teraz istnieje!) i patrzymy, w ilu miejscach kod wybucha. Liczba błędów kompilacji jest mierzalna, dodatkowo kompilator pokazuje czarno na białym linijki, którymi się trzeba zaopiekować. Możemy chociażby wyrywkowo oszacować, czy docelowe zmiany będą raczej powierzchowne, czy inwazyjne. Low hanging fruit.
- niestety, przez cały czas możemy mnożyć pieniądze, potęgować, całkować i kto wie co jeszcze.
- co gorsza, liczba nóg krowy jest kompatybilna z naszym typem Money. Jedno number i drugie number. Alias typu w TypeScripcie to jedynie „alias”, czyli nowa nazwa, a pod spodem istnieje ten sam typ co wcześniej.
Być może ta niewielka zmiana – mały krok dla człowieka, ale wielki skok dla projektu ;) – jest póki co wystarczający. I nie chcemy iść dalej. Sztuka w architekturze polega między innymi na tym, aby rozwiązywać faktyczne problemy, a nie domniemane. I żeby wskutek naszych zmian rachunek zysków i strat był na plusie. Zmiana, w której narobimy się jak dzikie osły – a korzyść jest niewielka – niestety powinna zostać wycofana. Człowiek jest emocjonalnie związany z potem i znojem, wysiłkiem, jaki władował w kod… tylko iż ten kod dopiero wyląduje na produkcji i inni będą go musieli utrzymywać. Miejmy z tyłu głowy, iż to, co piszemy, inni będą musieli rozkminiać. I to wielokrotnie. Czasami coś musi być skomplikowane, bo tak działa biznes (essential complexity), a czasami jest to przeinżynierowane – i można to, i tamto zwyczajnie usunąć (accidental complexity). Tak więc, zanim poszarżujemy dalej, zanim zainwestujemy w kolejne rozwiązanie, przeanalizujmy, jakie są jego koszty i zyski, zanim się w nie władujemy.
Fajnie, fajnie. Tylko ta myśl, iż mogę przypisać liczbę nóg krowy do typu type Money = number, jakoś nie daje spokoju…
Structural Typing
A gdyby tak stworzyć nowy typ (lub interfejs)? To jeden z pierwszych pomysłów, jakie przychodzą do głowy, zwłaszcza jeżeli mamy doświadczenie z technologiami takimi jak Java czy .NET. Tworzę nowy interfejs/klasę i – dopóki go nie rozszerzam – jest on niekompatybilny z całą resztą typów. Taka cecha systemów typów nazywa się „typowanie nominalne”: jeżeli dwa interfejsy (lub dwie klasy) są niekompatybilne, jeżeli nie są w żaden sposób „spokrewnione”, to znaczy ani nie implementują wspólnego interfejsu, ani jedna po drugiej nie dziedziczy. Programiści backendowi początkujący w TS-ie często zakładają, iż skoro TS bazuje na typowaniu statycznym, tak jak Java – to może kompatybilność interfejsów (i cała mechanika polimorfizmu) również będzie podobna do tej Javowej. A skoro o tym mówimy, to najpewniej tak nie jest :)
Bardzo istotną i szczególną cechą TypeScripta jest jego cel. W wielkim skrócie sprowadza się do: „weźmy JavaScript taki, jaki jest – ładny czy brzydki, nowy czy stary, nieważne – i opakujmy go typami, aby wychwytywać błędy”. Wśród kilku punktów wyszczególnionych w TypeScript Design Goals możemy przeczytać między innymi: „utrzymanie runtime’owego zachowania JavaScriptu”. To bardzo istotny punkt – strategicznym założeniem TS-a jest to, iż ma NIE zmieniać sposobu, w jaki działa JavaScript. To znaczy ma nie zmieniać semantyki, ma nie dodawać nowych JS-owych konstruktów (wyjątków jest bardzo mało, na przykład dekoratory), nie tworzyć własnego środowiska uruchomieniowego, i tym podobne. Ogólnie – ma być bezpieczną, type-safe wersją JavaScriptu, która w dodatku kompiluje się do niemalże takiego samego JavaScriptu, jaki sami byśmy napisali – tyle iż bez typów. I jeżeli programiści używali JS-a w konkretny sposób, to TS powinien co najwyżej wyłapać błędy związane z typami, ale nie wymuszać innego podejścia, paradygmatu. A na koniec ma zniknąć :) (fully erasable).
Weźmy pod uwagę to, iż w JS-ie nie ma interfejsów – i nic nie zapowiada, aby się miały pojawić. W zamian powszechnie stosowany jest duck-typing, który najczęściej sprowadza się do „macania” obiektu, czy przypadkiem może akurat ma zdefiniowaną jakąś metodę – jeżeli tak, to wywołaj, a nie – to olej. Albo zdefiniowane jakieś property – jeżeli tak, to bierzemy – a jeżeli nie, to – „proszę, tu masz wartość domyślną”. Sprawdzanie, czy property istnieje, jest w JavaScripcie czymś powszechnym i oczywistym. W JavaScripcie, czyli w runtime. A skoro typy znikają (fully erasable), to w runtime nie ma już informacji o typach, dopóki nie stosujemy czarnej magii refleksji, która ma sens jedynie dla bibliotek/frameworków. Więc skoro informacji o typach, dostępnej w czasie kompilacji, w runtime już nie ma – i w JavaScripcie powszechne jest bezpieczeństwo typów oparte o duck typing – i TypeScript ma zachować semantykę JS-a – to o co należałoby się oprzeć? :)
TypeScript wśród swoich celów ma również typowanie „strukturalnie”, co stoi w opozycji do typowania „nominalnego”. W typowaniu strukturalnym kompilatora nie interesuje, skąd się interfejs wziął… czy to interfejs, czy typ… czy może klasa… czy coś po sobie dziedziczy, rozszerza, przecina, i tak dalej. Ważne są jedynie struktury i ich zawartość – bez względu na „pochodzenie” i „nazwy” tych struktur (łac. nominal – nazwa). jeżeli oczekuję, iż dostanę obiekt, który ma metodę then() -> Promise, to przyjmę choćby papieża – o ile ten implementuje tę metodę.
Czy to oznacza, iż OOP, jakie znamy, nie ma w TS-ie zastosowania? Oczywiście, iż MA zastosowanie, jak najbardziej. Tylko nie na podstawie pochodzenia interfejsów, a na podstawie struktur. Polimorfizm nie jest oparty o implementowanie interfejsu, a o to, czy wymagana zawartość struktury jest spełniona, czy nie. Na przykład w poniższym kodzie:
funkcja schedule oczekuje typu Runnable jako parametru. Ale możemy przekazać jej każdy obiekt, który będzie implementował funkcję run(). Na przykład:
też się nada. „Na dobre i na złe”. Jak widać, obiekt nie musi być instancją klasy ani choćby zawierać anotacji typu (jeśli jej nie ma, to uruchomi się wnioskowanie – i tak czy siak wyrażenie otrzyma jakiś typ). Wreszcie, co jest istotne, typowanie strukturalne ma zastosowanie zarówno do typów obiektowych, jak i typów prymitywnych.
W konsekwencji wszystkie poniższe typy są ze sobą w pełni kompatybilne:
type Money = number type Debt = number type Balance = numberMożemy nie tylko tworzyć wiele aliasów typów, wielorakie interfejsy, klasy, możemy je implementować, dziedziczyć po nich…, cuda wianki. TypeScripta obchodzi jedynie zawartość danego typu, danej struktury – i tylko na tej podstawie określi kompatybilność. Tak więc javowo-dotnetowe tworzenie osobnych interfejsów po to, aby zablokować kompatybilność, ma się do TS-a trochę nijak, bo to inna bajka.
Określiliśmy fundamentalne reguły, którymi rządzi się kompatybilność typów.
Mając tę bazę możemy już budować…
…konkretne rozwiązania!
Czas wcielić w życie wiedzę o kompatybilności typów: skupimy się na budowaniu konkretnych rozwiązań z wykorzystaniem TypeScripta.
UWAGA! Właśnie trwa nabór do 1. Edycji Programu ANF: Architektura Na Froncie!
Zapraszamy serdecznie, tam nauczysz się frontendu na Poziomie PRO!
Zapisy zamykamy 30 czerwca o 21:00!
Wpadaj do nas TUTAJ »
Opaque Types
Jeśli chcemy zablokować przypisanie zwykłego number do naszego specyficznego (to znaczy bardziej doprecyzowanego) typu Money, to możemy w ten sposób skorzystać z typowania strukturalnego:
type Money = number & { readonly type: unique symbol }Nasz alias składa się teraz z dwóch elementów: number jako taki oraz sztuczny dodatek na potrzeby kompilatora, czyli { readonly type: unique symbol }. Z chęcią poruszyłbym temat semantyki przecięć typów oraz ich reguł kompatybilności, natomiast to zasługuje na osobny wpis (i będzie omawiane podczas Architektury na Froncie). Najistotniejsze dla nas jest teraz to, iż chcąc przypisać jakiekolwiek wyrażenie do typu Money, to wyrażenie musi „spełniać wymagania” nie tylko typu number, ale i { readonly type: unique symbol }. I tego drugiego nie spełni nic poza innymi wyrażeniami Money – a o to nam dokładnie chodzi! Ta druga składowa to sztuczny obiekt z polem readonly type, którego wartością jest unikalny symbol… Brzmi grubo – nie ma co ukrywać, zwiększa próg wejścia zrozumienia kodu. W rozwiązaniu ważne jest to, iż ten drugi człon jest tylko do wglądu dla kompilatora – w runtime wcale go nie będzie. Takie troszkę oszustwo, ale działa:
type Money = number & { readonly type: unique symbol } declare let m: Money declare let n: number m = n // ❌ Type 'number' is not assignable to type 'Money'. n = m // ✅Ufff… już nie można przypisać liczby nóg krowy do Money. Dlaczego? Bo liczba (nóg krowy) nie zawiera tego dodatkowego elementu { readonly type: unique symbol }. W runtime żadne z wyrażeń go nie zawierają, ale w czasie kompilacji liczy się to, co widzi kompilator. Szczegóły tego zjawiska omówimy w Architekturze na Froncie. Kompilator widzi, iż typ Money to number plus coś jeszcze. Teraz, aby stworzyć zmienną Money, potrzebujemy trochę oszukać kompilator. jeżeli podstawimy zwykłą liczbę, kompilator przecież tego nie przepuści:
const money: Money = 99.99 // ❌ Type 'number' is not assignable to type 'Money'.Musimy zatem wymusić na kompilatorze, iż w niektórych miejscach my „wiemy lepiej” niż on:
const asMoney = (value: number) => value as Money const money = asMoney(99.99) // ✅ MoneyZ byciem mądrzejszym od kompilatora (czyli poprawianiem wyników jego analiz) trzeba uważać, bo często możemy nie mieć racji i w konsekwencji błędy nie będą wychwytywane przez kompilator. To jest temat rzeka i również będzie omawiany w Architekturze na Froncie. Tutaj jednak zakres zmian jest bardzo mały i kontrolujemy go. Osiągnęliśmy to, iż nie możemy typu number przypisać do Money. Pozostaje jednak otwarta kwestia dozwolonych operacji, bo nie tylko potęgowanie pieniędzy jest dozwolone: money ** money, ale i operacje na styku Money i number: money + 10. A to dlatego, iż zablokowaliśmy jedynie przypisanie, a nie operatory.
Value Objects
Możemy pójść o krok dalej i zastosować pochodzący z DDD pattern Value Object. Jest to rozwiązanie nieco bardziej inwazyjne, bo wymaga więcej kodu i w deklaracji, i w miejscach użycia. Opiera się on na stworzeniu obiektu reprezentującego wartość. I jedynie wartość. Będzie z założenia niemutowalny – bo „10 złotych” jako wartość nie zmienia się w czasie (nominalnie inflację pomijamy :P). Nie będzie też miał swojej tożsamości (ID), czyli na przykład pieniądz.
class Money { private constructor( private value: number ){} static from(value: number){ return new Money(value) } add(another: Money){ return new Money(this.value + another.value) } multiply(factor: number){ return new Money(this.value * factor) } valueOf(){ return this.value } }Dla uproszczenia nie uzupełniliśmy go o walutę. W razie potrzeby należy dodać nowe pole plus obsłużyć je potencjalnie we wszystkich metodach. I to jest cała idea Value Object – obsługa struktury danych zostaje zamknięta (zaenkapsulowana) w strukturze danych – i nie wycieka do komponentu, kontrolera czy gdziekolwiek indziej. Traktujemy wartość razem z jej regułami jako spójną całość.
Zerknijmy, jak nasz VO radzi sobie w akcji:
const m = _Money.from(99.99) // deklaracja m + 4 // ❌ Operator '+' cannot be applied to types 'Money' and 'number'. const n: number = _m // ❌ is not assignable to type 'number' const sum = _m.add( _Money.from(1.23) ) // ✅ Money const product = _m.multiply( 2 ) // ✅ MoneyCałkiem nieźle. Zabezpieczyliśmy niechciane podstawienia oraz niechciane operacje. Jednocześnie umożliwiliśmy mu tylko te operacje, które w naszym biznesie są dozwolone.
Nie sposób nie zauważyć, iż to rozwiązanie w porównaniu z aliasami typów jest znacznie bardziej inwazyjne. Kiedy warto je stosować? O tym zaraz.
Aplikacja, domena i typy
Wracając do tematu rozszerzenia pieniędzy w całej aplikacji tak, aby obejmowała również różne waluty, prędzej czy później pojawia się pytanie: kiedy należy stosować aliasy, opaque types, VO, a kiedy jechać na prymitywach? Jednoznacznej odpowiedzi nie ma, natomiast można wyróżnić kilka wskazówek-pytań, które pomogą nam określić, czy pozostajemy z typem prymitywnym, czy lepiej zamodelować to jako osobny typ, explicite:
- czy to, co otypowaliśmy jako string, to faktycznie dowolny string? Na przykład imię firstName: string – wprawdzie istnieje skończona liczba imion (przynajmniej w Polsce), ale bez przesady – imię to po prostu tekst, który nie ma swojego dodatkowego znaczenia. Albo komentarz comment: string – to po prostu tekst. Ale na przykład stanowisko pracownika – position? Być może pierwotnie będzie to po prostu string z wartościami JavaScript Developer. Ale jeżeli pracujemy nad systemem kadrowym, w którym chcemy domenowo rozróżniać Senior od Junior,to z czasem stanowisko może przestać być stringiem i stać się obiektem z kilkoma polami – { title: string, level: ENUM }. I alias będzie jak znalazł.
- czy element, który chcemy otypować, funkcjonuje samodzielnie w naszym biznesie? Przykładowo w aplikacji operującej na finansach pieniądz pojawia się wielokrotnie i nie jest po prostu liczbą taką, jak każda inna. Bo ma swoje dodatkowe znaczenie W aplikacji HR-owej umiejętność JavaScript to nie musi być po prostustring. JavaScript, który ktoś zna od roku – i JavaScript, który ktoś trzaska od 10 lat w wielu projektach – to nie to samo. I prędzej czy później biznes będzie chciał to rozróżnić. Choćby dlatego, iż kontraktornia, podsyłając CV kandydatów „grubemu globalnemu graczowi”, chce posortować malejąco kandydatów według skomplikowanych kryteriów. A kandydatów jest sporo.
Generalnie, nie jesteśmy w stanie z góry przewidzieć, które typy w przyszłości się zmienią. Rozsądne zatem wydaje się zacząć z aliasem typu tam, gdzie ma on samodzielne biznesowe znaczenie (na przykład pieniądz) – i rozszerzać go w przyszłości, kiedy pojawi się wymaganie biznesowe. Lub jeżeli programiści zauważą, iż często zdarzają się bugi, które na poziomie TypeScripta dałoby się rozwiązać. Ta mała inwestycja – ale zaaplikowana odpowiednio wcześnie – kosztuje bardzo mało, a zapewnia elastyczność. Nie mówiąc o łatwiejszym rozumieniu kodu – jeżeli w kodzie jest Money, a nie number, to nie tylko więcej wiem o tych danych, czytając kod – ale także łatwiej mi wyszukać wystąpienia tego typu.
Miejmy zawsze z tyłu głowy, iż wypuszczenie kodu na produkcję to dopiero początek potencjalnie długiego życia tego kodu. jeżeli pracujemy w agencji interaktywnej i tworzymy apkę reklamową z czerwoną ciężarówką słodkiego napoju, pędzącego na tle zimowej scenerii – to po świętach nasza apka zostanie zaorana i świat o niej zapomni. Długoterminowe inwestowanie w jakość… nie ma sensu. Ale jeżeli tworzymy systemy biznesowe, to ktoś będzie je z większym lub mniejszym bólem utrzymywał. Perspektywa łatwego tworzenia kodu – i perspektywa łatwego jego późniejszego utrzymania – to często dwie różne perspektywy. Implementowanie aliasu zamiast prymitywa może nam nie zrobić wielkiej różnicy w momencie kodowania. Ale jeżeli będziemy chcieli zmienić go kilka lat później, to dopiero wtedy się okaże, czy task kosztuje jeden tydzień czy dwa miesiące.
Podsumowanie
Omówiliśmy, po co, kiedy, kiedy nie i w jaki sposób można stworzyć alias typów reprezentujących dane domenowe. Poruszony tu temat to zaledwie wierzchołek góry lodowej możliwości TypeScripta :).
Aby wejść głębiej w zagadnienia type-safety, projektowania aplikacji i wielu innych wątków frontendowych, zapraszam na Architekturę na Froncie.