HomeO mnie

Mój ulubiony typ: ten o którym nic nie wiem

By Robert Duraj
Published in TypeScript
August 08, 2022
5 min read

Nigdy nie pomyślałbym, że najciekawszym typem typescriptowym okaże się dla mnie unknown. Ba! Przez długi czas nie wiedziałem nawet o jego istnieniu. Tymczasem nagle przestałem wyobrażać sobie programowanie bez niego. Nie dlatego, że ma wyjątkowe właściwości, czy też mogę wykonać na nim niezwykłe operacje. Wręcz przeciwnie - mało co można z nim zrobić! Skąd więc to zauroczenie?! Zapraszam do lektury.

An(t)ypattern

Każdy, kto zaczyna swoją przygodę z TS, bardzo szybko zaprzyjaźnia się z typem nie-typem, czyli any. Relacja ta jest o tyle toksyczna, że bardzo ciężko się z niej wyrwać. A im bardziej się w niej pogrążamy, tym mniej użyteczny staje się nasz kod typescriptowy. Do tego stopnia, że możemy nawet dojść do wniosku, że te całe typy, to nie są nam wcale tak potrzebne…

Musimy sobie zdać na początku sprawę z tego, że any jest niezbędny, by TS mógł zaistnieć w środowisku. JavaScript jest językiem złożonym, a TypeScript próbuje mu nadać zupełnie nowy ton. I to “nadawanie tonu”, czyli migrowanie z jednego języka na drugi jest właśnie miejscem, w którym any jest nam potrzebny. Gorzej, jeśli zdecydujemy, że nigdy nie przestajemy migrować. Wtedy nie ma ratunku. Wykorzystanie any w kodziku jest traktowane jako antypattern i powinno to być egzekwowane z pełną surowością. Nie bez kozery mamy dedykowaną właściwość noImplicitAny (tsconfig) czy regułę noExplicitAny (eslint).

Osoby stawiające pierwsze kroki w TS bardzo często nadużywają any, choć ich intencje nie są złe. Korzystają z niego tam, gdzie w rzeczywistości nie wiedzą, jaki jest docelowy typ danych. Nie powinno to być usprawiedliwieniem. Od TS 3.0 mamy możliwość w takich miejscach określenia typu jako unknown. I to jest właśnie moment w którym gwiazda wieczoru wchodzi na scenę. 🎉

any na sterydach

Czym różni się unknown od any? Zakładam, że to pytanie, jak mantra, pojawia się na wielu rozmowach rekrutacyjnych. Problem w tym, że nawet wiedząc, że z unknown mamy ograniczone możliwości wywoływania metod czy sprawdzania pól, kandydaci często nie rozumieją, na czym polega jego fenomen. Skoro nie możemy nic z nim zrobić, to jaki jest cel posiadania takiego typu?!

Jako dziecko lubiłem zabawę w odgadywanie różnych rzeczy, bez patrzenia. Wycinaliśmy w kartonie dziury na ręce, jedna osoba wkładała do środka jakiś przedmiot a kolejna próbowała, dotykając, zgadnąć, co jest w środku. W podobny sposób myślę o unknown.

— Wiemy, że jest:

const iDontKnow = (val: unknown) => {
val;
};

— Nie wiemy czym jest, dlatego nie możemy go przypisać do zmiennej konkretnego typu:

const iDontKnow = (val: unknown) => {
const newString: string = val; // Error: Type 'unknown' is not assignable to type 'string'.
// ...
};

— Nie wiemy, jak go użyć, dlatego nie możemy wywołać na nim żadnej metody:

const iDontKnow = (val: unknown) => {
val.doSomething(); // Error: Object is of type 'unknown'.
};

— Nie wiemy też, jak smakuje, jak wygląda, więc nie znamy jego propertiesów:

const iDontKnow = (val: unknown) => {
val.someProperty; // Error: Object is of type 'unknown'.
};

Bezużyteczny. Zgadza się. Naszym zadaniem jest sprawienie, żeby jednak do czegoś się przydał. Jest na to kilka sposobów. Mniej lub bardziej eleganckich.

Asercja typu

Najprostsze podejście. Nieco ocierające się o any. Korzystając z asercji typu i słowa kluczowego as, decydujemy się na wymądrzanie przed kompilatorem i sugerowanie, że to my wiemy lepiej, a on niech schowa swoje obliczenia do kieszeni.

Musimy być bardzo silnej wiary w to, co znajduje się w danej zmiennej, żeby powiedzieć explicite, że to nie jest unknown tylko string. A jednak, zaskakująco często decydujemy się na ten ryzykowny krok. Ilekroć jednak korzystamy z as, cierpi na tym nasz projekt, ponieważ obowiązek identyfikacji typu wartości spada już na nasze barki, bierzemy na klatę wszystkie ewentualne pomyłki z tym związane.

Jak to działa?

const iKnow = (val: unknown): string => {
return val as string; // 👌
};
iKnow(2); // 🙃

Wmówiliśmy właśnie kompilatorowi, że number to string… This is fine.

Type predicates

Podobną metodą, która pomaga w określeniu typu jest tworzenie tzw. type predicates (zwanych wcześniej “type guards”). Są to funkcje, których jedynym zadaniem jest określenie czy dany input jest konkretnego typu. Wynikiem takiej funkcji jest boolean, ale TypeScript na podstawie tego wyniku przypisuje do “badanej” zmiennej konkretny typ.

Oto przykład działania takich predykatów:

type Product = {
productName: string;
};
const isProduct = (probablyProduct: unknown): probablyProduct is Product => {
return (
typeof probablyProduct === "object" &&
probablyProduct !== null &&
"productName" in probablyProduct
);
};
const iKnow = (val: unknown): string => {
return isProduct(val)
? val.productName // 👌 Nie ma błędu. Typ jest zweryfikowany jako Product, więc na pewno ma to pole.
: ""; // Fallback, żeby funkcja zwracała stringa nawet jeśli input jest innego typu
};

isProduct odwala całą brudną robotę. Posłużyliśmy się tutaj metodą kaczki. Sprawdzamy czy to, co badamy: chodzi jak kaczka, kwacze jak kaczka - jeśli tak: musi być kaczką! W tym konkretnym przypadku sprawdzamy, czy wartość: 1) nie jest nullem; 2) jest obiektem 3) zachowuje się jak oczekiwany obiekt (czyli w tym przypadku ma pole productName).

Kluczowym jest tutaj zapis typu zwracanej wartości probablyProduct is Product, co oznacza, że funkcja z którą mamy do czynienia to type predicate.

Dekodery

Type predicates są super, jeśli mamy do czynienia z niewielkimi obiektami. Sprawdzenie dwóch czy trzech pól lub metod nie stanowi większego problemu. Przypuśćmy jednak, że nasz Product przychodzi z API. Mało tego: nie zawiera jednego a 10 pól. I jest jedną z 15 typów zwrotek z tego API. Pisanie metod sprawdzających, oczywiście, wciąż może się udać, ale, na szczęście, są lepsze sposoby.

Na ratunek przychodzą wszelkiego rodzaju dekodery, którymi nieznane typy zamieniamy na takie, które sami zdefiniowaliśmy (oczywiście, o ile ich struktura się zgadza). Do wykonania takich operacji możemy się posłużyć np. biblioteką zod lub io-ts. Osobiście preferuję tę drugą (ze względu na osadzenie w ekosystemie fp-ts, o którym innym razem), jednak ich działanie opiera się na tych samych zasadach.

Dekodowanie odbywa się na bazie wcześniej przygotowanego schematu. Tworzymy go, za pomocą metod, dostarczonych przez bibliotekę. Zamieniamy zatem znane nam typy z TS na obiekty, które odzwierciedlają te typy. W przypadku io-ts i naszego produktu, typ wyglądałby w nastepujący sposób:

import * as t from "io-ts";
const Product = t.type({
productName: t.string,
});

Wygląda dziwacznie? Trochę tak, jednak korzyści są długofalowe. Product zawiera teraz kodek, który możemy wykorzystać do sprawdzenia wartości (w obie strony!). Jeśli potrzebujemy ekwiwalent typu w TS, możemy go wyprodukować na bazie takiego kodeka, korzystając z type Product = t.TypeOf<typeof Product>. Więcej o samym io-ts napiszę wkrótce na blogu, jednak zerknijmy jeszcze na sposób działania:

import * as t from "io-ts";
import { isRight } from "fp-ts/Either";
const Product = t.type({
productName: t.string,
});
const product: unknown = { productName: "Poprawny produkt" };
const notProduct: unknown = { produktName: "Produkt z literówką" };
isRight(Product.decode(product)); // true
isRight(Product.decode(notProduct)); // false

Pojawiła się tutaj metoda isRight z biblioteki fp-ts, ale - na ten moment - dosyć powiedzieć, że głównie sprawdza ona czy Product.decode zwraca typ { _tag: 'Right', value: _nasz_produkt_ }; Jeśli jesteś ciekaw dlaczego właśnie w ten sposób i jakie korzyści to daje - zapraszam do odwiedzania bloga częściej, bo fp-ts będzie się tutaj przewijał częściej.

Jeśli nie chcesz zagłębiać się w meandry programowania funkcyjnego, zod będzie dobrym wyborem, gdzie schematy możemy w prosty sposób sprawdzać, korzystając po prostu z metody parse:

const Product = z.object({
product: z.string(),
});
Product.parse(product);
Product.parse(notProduct);

Zasada działania, jak pisałem, jest ta sama, zmienia się jednak sposób obsługi rezultatu. W przypadku porażki zod wyrzuci wyjątek. Jest również metoda safeParse, która zwraca obiekt podobny do tego z io-ts: { success: true, data: T } | { success: false, error: ZodError }

Tyle zachodu o nic?

Przechodzimy do sedna tego artykułu. W kilku akapitach przeanalizowaliśmy, jak pracować z typem unknown. Nie da się ukryć, że narzuca on trochę dodatkowych obowiązków. Gdzie te korzyści?! Spieszę z wyjaśnieniami. Gdy zdamy sobie sprawę, w jaki sposób dane przepływają przez naszą aplikację, zauważymy, że oprócz tych, które sami stworzymy i nad którymi mamy pełną kontrolę, jest też wiele danych “z zewnątrz”, o których zawartości możemy domniemywać.

Klasyczny przykład - dane otrzymywane z API. Teoretycznie wiemy, czym są. Takie założenie prowadzi do katastrofy. Nikt nie daje nam 100% pewności, że w cyklu release’owania dane z backendu zawsze przyjdą takie, jakich oczekujemy. Nie mamy pewności, że dwa samodzielnie pracujące zespoły będą trzymały się kontraktu. Co dzieje się, gdy następuje rozjazd pomiędzy oczekiwanymi a dostarczonymi danymi? Cała nasza warstwa type-safety bierze w łeb, a aplikacja wybucha w całkiem nieoczekiwanych miejscach. Możemy temu zapobiec.

Sprawdzanie danych IO, ale przede wszystkim - traktowanie ich jako niewiadome, to jeden z filarów dobrze skrojonej aplikacji TS. Mając kontrolę nad swoimi danymi (także tymi wejściowymi), zabezpieczamy naszą aplikację w stopniu, który daje całkowity komfort psychiczny. Odtąd wszystko zależy od nas. API zwróciło śmietnik? Nie ma problemu, dekodery nie wpuszczą nam go do aplikacji. Dostaliśmy nieaktualny model? Wszystko w porządku, dla nas to i tak tylko jeden wielki znak zapytania, który, jak przypuszczamy, może być wartościowym inputem.

Typ unknown zmienia podejście do patrzenia na frontend i aplikacje TypeScript. Świadomość niepewności danych IO jest uwalniająca, bo - choć wprowadza sporo nowych obowiązków - oddaje nam w ręce pełną kontrolę nad tym, co tworzymy.

Koniec z niespodziewanymi danymi. Wszystkie dane, które nie są częścią naszej aplikacji są uknonwn.


Tags

#typescript

Share

Previous Article
Dlaczego typy w JavaScript mnie nie cieszą

Robert Duraj

Software Engineer

Related Posts

Czy private jest private?
July 25, 2024
1 min
© 2024, All Rights Reserved.
Powered By

Social Media

linkedingithubtwitter