Zrozumienie generyków TypeScript
Opublikowany: 2022-03-10W tym artykule nauczymy się koncepcji Generics w TypeScript i zbadamy, jak Generics może być używany do pisania modularnego, oddzielonego i wielokrotnego użytku kodu. Po drodze omówimy pokrótce, w jaki sposób pasują do lepszych wzorców testowania, podejść do obsługi błędów oraz separacji domeny/dostępu do danych.
Przykład z prawdziwego świata
Chcę wejść w świat Generic nie wyjaśniając, czym one są, ale raczej przedstawiając intuicyjny przykład, dlaczego są przydatne. Załóżmy, że masz za zadanie zbudować bogatą w funkcje listę dynamiczną. Możesz nazwać to tablicą, ArrayList
, List
, std::vector
, lub cokolwiek, w zależności od twojego języka. Być może ta struktura danych musi mieć również wbudowane lub wymienialne systemy buforowe (jak opcja wstawiania bufora cyklicznego). Będzie to opakowanie wokół normalnej tablicy JavaScript, dzięki czemu będziemy mogli pracować z naszą strukturą zamiast ze zwykłymi tablicami.
Bezpośrednim problemem, z jakim się spotkasz, są ograniczenia narzucone przez system typów. W tym momencie nie możesz zaakceptować żadnego typu, który chcesz w funkcji lub metodzie w przyjemny, przejrzysty sposób (ponownie przyjrzymy się temu stwierdzeniu później).
Jedynym oczywistym rozwiązaniem jest replikacja naszej struktury danych dla wszystkich typów:
const intList = IntegerList.create(); intList.add(4); const stringList = StringList.create(); stringList.add('hello'); const userList = UserList.create(); userList.add(new User('Jamie'));
.create()
tutaj może wyglądać na arbitralną i rzeczywiście, new SomethingList()
byłaby prostsza, ale później zobaczysz, dlaczego używamy tej statycznej metody fabrycznej. Wewnętrznie metoda create
wywołuje konstruktor.
To jest okropne. Mamy dużo logiki w tej strukturze kolekcji i rażąco powielamy ją, aby obsługiwać różne przypadki użycia, całkowicie łamiąc w tym procesie zasadę DRY. Kiedy zdecydujemy się zmienić naszą implementację, będziemy musieli ręcznie propagować/odzwierciedlać te zmiany we wszystkich obsługiwanych przez nas strukturach i typach, w tym typach zdefiniowanych przez użytkownika, jak w ostatnim przykładzie powyżej. Załóżmy, że sama struktura kolekcji miała długość 100 wierszy — koszmarem byłoby utrzymywanie wielu różnych implementacji, w których jedyną różnicą między nimi są typy.
Natychmiastowym rozwiązaniem, które może przyjść do głowy, zwłaszcza jeśli masz nastawienie OOP, jest rozważenie głównego „nadtypu”, jeśli chcesz. Na przykład w C# typ składa się z nazwy object
, a object
jest aliasem klasy System.Object
. W systemie typów języka C# wszystkie typy, czy to predefiniowane, czy zdefiniowane przez użytkownika i odwołujące się do typów lub typów wartości, dziedziczą bezpośrednio lub pośrednio z System.Object
. Oznacza to, że do zmiennej typu object
można przypisać dowolną wartość (bez wchodzenia w semantykę stosu/sterty i pakowania/rozpakowywania).
W takim przypadku nasz problem wydaje się być rozwiązany. Możemy po prostu użyć typu takiego jak any
, który pozwoli nam przechowywać wszystko, co chcemy w naszej kolekcji, bez konieczności powielania struktury, i rzeczywiście, jest to bardzo prawdziwe:
const intList = AnyList.create(); intList.add(4); const stringList = AnyList.create(); stringList.add('hello'); const userList = AnyList.create(); userList.add(new User('Jamie'));
Spójrzmy na rzeczywistą implementację naszej listy przy użyciu any
:
class AnyList { private values: any[] = []; private constructor (values: any[]) { this.values = values; // Some more construction work. } public add(value: any): void { this.values.push(value); } public where(predicate: (value: any) => boolean): AnyList { return AnyList.from(this.values.filter(predicate)); } public select(selector: (value: any) => any): AnyList { return AnyList.from(this.values.map(selector)); } public toArray(): any[] { return this.values; } public static from(values: any[]): AnyList { // Perhaps we perform some logic here. // ... return new AnyList(values); } public static create(values?: any[]): AnyList { return new AnyList(values ?? []); } // Other collection functions. // ... }
Wszystkie metody są stosunkowo proste, ale zaczniemy od konstruktora. Jego widoczność jest prywatna, ponieważ założymy, że nasza lista jest złożona i chcemy zabronić dowolnej konstrukcji. Możemy również chcieć wykonać logikę przed konstrukcją, więc z tych powodów i aby zachować czystość konstruktora, delegujemy te kwestie do statycznych metod fabryki/pomocnika, co jest uważane za dobrą praktykę.
Dostępne są metody statyczne from
i create
. Metoda from
akceptuje tablicę wartości, wykonuje niestandardową logikę, a następnie używa ich do konstruowania listy. Metoda create
static przyjmuje opcjonalną tablicę wartości w przypadku, gdy chcemy zapełnić naszą listę danymi początkowymi. „Zerowy operator łączenia” ( ??
) służy do konstruowania listy z pustą tablicą w przypadku jej braku. Jeśli lewa strona operandu jest null
lub undefined
, wrócimy do prawej strony, ponieważ w tym przypadku values
są opcjonalne, a zatem mogą być undefined
. Więcej informacji o koalescencji zerowej można znaleźć na odpowiedniej stronie dokumentacji języka TypeScript.
Dodałem również metodę select
i where
. Te metody po prostu zawijają odpowiednio map
i filter
JavaScript. select
pozwala nam rzutować tablicę elementów do nowego formularza w oparciu o podaną funkcję selektora, a where
pozwala nam odfiltrować niektóre elementy na podstawie dostarczonej funkcji predykatu. Metoda toArray
po prostu konwertuje listę na tablicę, zwracając odwołanie do tablicy, które przechowujemy wewnętrznie.
Na koniec załóżmy, że klasa User
zawiera metodę getName
, która zwraca nazwę, a także przyjmuje nazwę jako swój pierwszy i jedyny argument konstruktora.
Uwaga: niektórzy czytelnicy rozpoznająWhere
iSelect
z LINQ języka C#, ale pamiętaj, że staram się zachować prostotę, dlatego nie martwię się o lenistwo lub odroczone wykonanie. Są to optymalizacje, które można i należy wprowadzić w prawdziwym życiu.
Ponadto, jako ciekawą uwagę, chcę omówić znaczenie „orzecznika”. W matematyce dyskretnej i logice zdań mamy pojęcie „zdania”. Zdanie to pewne stwierdzenie, które można uznać za prawdziwe lub fałszywe, takie jak „cztery jest podzielne przez dwa”. „Orzeczenie” to zdanie, które zawiera jedną lub więcej zmiennych, a zatem prawdziwość zdania zależy od prawdziwości tych zmiennych. Możesz myśleć o tym jak o funkcji, takiej jakP(x) = x is divisible by two
, ponieważ musimy znać wartośćx
, aby określić, czy zdanie jest prawdziwe czy fałszywe. Możesz dowiedzieć się więcej o logice predykatów tutaj.
Istnieje kilka problemów, które wynikną z używania any
. Kompilator TypeScript nie wie nic o elementach wewnątrz listy/tablicy wewnętrznej, dlatego nie zapewni żadnej pomocy wewnątrz where
lub select
ani podczas dodawania elementów:
// Providing seed data. const userList = AnyList.create([new User('Jamie')]); // This is fine and expected. userList.add(new User('Tom')); userList.add(new User('Alice')); // This is an acceptable input to the TS Compiler, // but it's not what we want. We'll definitely // be surprised later to find strings in a list // of users. userList.add('Hello, World!'); // Also acceptable. We have a large tuple // at this point rather than a homogeneous array. userList.add(0); // This compiles just fine despite the spelling mistake (extra 's'): // The type of `users` is any. const users = userList.where(user => user.getNames() === 'Jamie'); // Property `ssn` doesn't even exist on a `user`, yet it compiles. users.toArray()[0].ssn = '000000000'; // `usersWithId` is, again, just `any`. const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Oops, it's "id" not "ID", but TS doesn't help us. // We compile just fine. console.log(usersWithId.toArray()[0].ID);
Ponieważ TypeScript wie tylko, że typ wszystkich elementów tablicy to any
, nie może nam pomóc w czasie kompilacji z nieistniejącymi właściwościami lub funkcją getNames
, która nawet nie istnieje, dlatego ten kod spowoduje wiele nieoczekiwanych błędów w czasie wykonywania .
Szczerze mówiąc, sprawy zaczynają wyglądać dość ponuro. Próbowaliśmy wdrożyć naszą strukturę danych dla każdego konkretnego typu, który chcieliśmy obsługiwać, ale szybko zdaliśmy sobie sprawę, że nie da się tego w żaden sposób utrzymać. Potem pomyśleliśmy, że do czegoś dochodzimy, używając any
, co jest analogiczne do uzależnienia od nadtypu głównego w łańcuchu dziedziczenia, z którego wywodzą się wszystkie typy, ale doszliśmy do wniosku, że dzięki tej metodzie tracimy bezpieczeństwo typów. Jakie jest więc rozwiązanie?
Okazuje się, że na początku artykułu skłamałem (tak jakby):
„W tym momencie nie możesz zaakceptować żadnego typu, który chcesz w funkcji lub metodzie w przyjemny, czysty sposób”.
Właściwie możesz i tu właśnie pojawiają się Generics. Zauważ, że powiedziałem „w tym momencie”, ponieważ zakładałem, że nie wiedzieliśmy o Generykach w tym momencie artykułu.
Zacznę od pokazania pełnej implementacji naszej struktury List z Generics, a następnie cofniemy się o krok, omówimy, czym one właściwie są, i określimy ich składnię w bardziej formalny sposób. Nazwałem ją TypedList
, aby odróżnić ją od naszej wcześniejszej AnyList
:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
Spróbujmy jeszcze raz popełnić te same błędy, co wcześniej:
// Here's the magic. `TypedList` will operate on objects // of type `User` due to the `<User>` syntax. const userList = TypedList.create<User>([new User('Jamie')]); // The compiler expects this. userList.add(new User('Tom')); userList.add(new User('Alice')); // Argument of type '0' is not assignable to parameter // of type 'User'. ts(2345) userList.add(0); // Property 'getNames' does not exist on type 'User'. // Did you mean 'getName'? ts(2551) // Note: TypeScript infers the type of `users` to be // `TypedList<User>` const users = userList.where(user => user.getNames() === 'Jamie'); // Property 'ssn' does not exist on type 'User'. ts(2339) users.toArray()[0].ssn = '000000000'; // TypeScript infers `usersWithId` to be of type // `TypedList<`{ id: string, name: string }> const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Property 'ID' does not exist on type '{ id: string; name: string; }'. // Did you mean 'id'? ts(2551) console.log(usersWithId.toArray()[0].ID)
Jak widać, kompilator TypeScript aktywnie pomaga nam w bezpieczeństwie typów. Wszystkie te komentarze są błędami, które otrzymuję od kompilatora, gdy próbuję skompilować ten kod. Generics pozwoliło nam określić typ, na którym chcemy zezwolić na działanie naszej listy, a od tego TypeScript może określić typy wszystkiego, aż do właściwości poszczególnych obiektów w tablicy.
Dostarczane przez nas typy mogą być tak proste lub złożone, jak byśmy tego chcieli. Tutaj możesz zobaczyć, że możemy przekazać zarówno prymitywne, jak i złożone interfejsy. Moglibyśmy również przekazać inne tablice, klasy lub cokolwiek:
const numberList = TypedList.create<number>(); numberList.add(4); const stringList = TypedList.create<string>(); stringList.add('Hello, World'); // Example of a complex type interface IAircraft { apuStatus: ApuStatus; inboardOneRPM: number; altimeter: number; tcasAlert: boolean; pushBackAndStart(): Promise<void>; ilsCaptureGlidescope(): boolean; getFuelStats(): IFuelStats; getTCASHistory(): ITCASHistory; } const aircraftList = TypedList.create<IAircraft>(); aircraftList.add(/* ... */); // Aggregate and generate report: const stats = aircraftList.select(a => ({ ...a.getFuelStats(), ...a.getTCASHistory() }));
Szczególne zastosowania T
i U
oraz <T>
i <U>
w TypedList<T>
są przykładami Generic w akcji. Po spełnieniu naszej dyrektywy tworzenia struktury kolekcji bezpiecznej dla typów, na razie zostawimy ten przykład za sobą i wrócimy do niego, gdy zrozumiemy, czym właściwie są Generics, jak działają i jaka jest ich składnia. Kiedy uczę się nowego konceptu, zawsze lubię zacząć od obejrzenia złożonego przykładu używanego konceptu, więc kiedy zaczynam uczyć się podstaw, mogę nawiązać połączenie między podstawowymi tematami a istniejącym przykładem, który mam w swoim głowa.
Co to są generyki?
Prostym sposobem zrozumienia Generics jest uznanie ich za względnie analogiczne do symboli zastępczych lub zmiennych, ale dla typów. Nie oznacza to, że możesz wykonać te same operacje na symbolu zastępczym typu ogólnego, co zmienną, ale zmienna typu ogólnego może być traktowana jako symbol zastępczy reprezentujący konkretny typ, który będzie używany w przyszłości. Oznacza to, że używanie Generics jest metodą pisania programów w kategoriach typów, które mają być określone w późniejszym czasie. Powodem, dla którego jest to przydatne, jest to, że pozwala nam budować struktury danych, które można ponownie wykorzystać w różnych typach, na których operują (lub niezależnie od typu).
Nie jest to szczególnie najlepsze wyjaśnienie, więc mówiąc prościej, jak widzieliśmy, często w programowaniu może być konieczne zbudowanie struktury funkcji/klasy/danych, która będzie działać na określonym typie, ale równie często taka struktura danych musi działać w wielu różnych typach. Gdybyśmy utknęli w sytuacji, w której musielibyśmy statycznie zadeklarować konkretny typ, na którym działała struktura danych w czasie projektowania struktury danych (w czasie kompilacji), bardzo szybko zauważylibyśmy, że musimy je przebudować. struktury w prawie dokładnie taki sam sposób dla każdego typu, który chcemy obsługiwać, jak widzieliśmy w powyższych przykładach.
Generyki pomagają nam rozwiązać ten problem, pozwalając nam odroczyć wymaganie konkretnego typu, dopóki nie zostanie on faktycznie poznany.
Generyki w TypeScript
Mamy teraz nieco organiczny pomysł na to, dlaczego Generics są przydatne i widzieliśmy ich nieco skomplikowany przykład w praktyce. W większości przypadków TypedList<T>
ma już prawdopodobnie dużo sensu, zwłaszcza jeśli pochodzisz ze statycznie wpisanego języka, ale pamiętam, że miałem trudności ze zrozumieniem koncepcji, gdy uczyłem się po raz pierwszy, dlatego chcę zbuduj ten przykład, zaczynając od prostych funkcji. Koncepcje związane z abstrakcją w oprogramowaniu mogą być notorycznie trudne do przyswojenia, więc jeśli pojęcie Generics jeszcze się nie sprawdziło, to jest w porządku i miejmy nadzieję, że pod koniec tego artykułu pomysł będzie przynajmniej nieco intuicyjny.
Aby zbudować możliwość zrozumienia tego przykładu, przejdźmy od prostych funkcji. Zaczniemy od „Funkcji tożsamości”, której lubi używać większość artykułów, w tym sama dokumentacja TypeScript.
„Funkcja tożsamości” w matematyce to funkcja, która odwzorowuje dane wejściowe bezpośrednio na dane wyjściowe, na przykład f(x) = x
. To, co wkładasz, jest tym, co dostajesz. Możemy to przedstawić w JavaScript jako:
function identity(input) { return input; }
Lub bardziej zwięźle:
const identity = input => input;
Próba przeniesienia tego do TypeScript przywraca te same problemy z systemem typów, które widzieliśmy wcześniej. Rozwiązania polegają na wpisywaniu any
, co, jak wiemy, rzadko jest dobrym pomysłem, duplikowaniu/przeciążaniu funkcji dla każdego typu (przerywa DRY) lub używaniu Generics.
W przypadku drugiej opcji możemy przedstawić funkcję w następujący sposób:
// ES5 Function function identity<T>(input: T): T { return input; } // Arrow Function const identity = <T>(input: T): T => input; console.log(identity<number>(5)); // 5 console.log(identity<string>('hello')); // hello
Składnia <T>
tutaj deklaruje tę funkcję jako Generic. Podobnie jak funkcja pozwala nam przekazać dowolny parametr wejściowy do swojej listy argumentów, za pomocą funkcji Generic możemy również przekazać parametr dowolnego typu.
Część <T>
podpisu identity<T>(input: T): T
i <T>(input: T): T
w obu przypadkach deklaruje, że dana funkcja zaakceptuje jeden parametr typu ogólnego o nazwie T
. Podobnie jak zmienne mogą mieć dowolną nazwę, tak samo mogą być nasze ogólne symbole zastępcze, ale zgodnie z konwencją używa się dużej litery „T” („T” dla „typu”) i przesuwania alfabetu w dół, jeśli to konieczne. Pamiętaj, że T
jest typem, więc stwierdzamy również, że zaakceptujemy jeden argument funkcji z nazwy input
typu T
i że nasza funkcja zwróci typ T
. To wszystko, co mówi podpis. Spróbuj pozwolić T = string
w twojej głowie — zamień wszystkie T
na string
w tych podpisach. Widzisz, jak nic tak magicznego się nie dzieje? Widzisz, jak podobny jest do nieogólnego sposobu korzystania z funkcji na co dzień?
Pamiętaj, co już wiesz o sygnaturach TypeScript i funkcji. Mówimy tylko, że T
jest dowolnym typem, który użytkownik poda podczas wywoływania funkcji, tak jak input
jest dowolną wartością, którą użytkownik poda podczas wywoływania funkcji. W takim przypadku input
musi być tym, czym jest typ T
, gdy funkcja zostanie wywołana w przyszłości .
Następnie, w „przyszłości”, w dwóch instrukcjach log, „przekazujemy” konkretny typ, którego chcemy użyć, tak jak robimy zmienną. Zwróć uwagę na przełącznik słowny tutaj — w początkowej formie <T> signature
, podczas deklarowania naszej funkcji jest on ogólny — to znaczy działa na typach ogólnych lub typach, które zostaną określone później. Dzieje się tak, ponieważ nie wiemy, jakiego typu program wywołujący będzie chciał użyć, gdy faktycznie napiszemy funkcję. Ale kiedy wywołujący wywołuje funkcję, wie dokładnie, z jakimi typami chce pracować, w tym przypadku są to string
i number
.
Możesz sobie wyobrazić pomysł zadeklarowania funkcji dziennika w ten sposób w bibliotece innej firmy — autor biblioteki nie ma pojęcia, jakich typów programiści korzystający z biblioteki będą chcieli używać, więc czynią tę funkcję ogólną, zasadniczo odraczając potrzebę dla konkretnych typów, dopóki nie zostaną faktycznie poznane.
Chcę podkreślić, że powinieneś myśleć o tym procesie w podobny sposób, jak myślisz o przekazywaniu zmiennej do funkcji w celu uzyskania bardziej intuicyjnego zrozumienia. Wszystko, co teraz robimy, to także przekazywanie typu.
W punkcie, w którym wywołaliśmy funkcję z parametrem number
, oryginalny podpis, dla wszystkich intencji i celów, mógłby być traktowany jako identity(input: number): number
. W momencie, w którym wywołaliśmy funkcję z parametrem string
, oryginalna sygnatura mogła równie dobrze być identity(input: string): string
. Możesz sobie wyobrazić, że podczas wywołania każde ogólne T
zostaje zastąpione konkretnym typem, który podajesz w danym momencie.
Odkrywanie ogólnej składni
Istnieją różne składnie i semantyki określania typów ogólnych w kontekście funkcji ES5, funkcji strzałek, aliasów typów, interfejsów i klas. Omówimy te różnice w tej sekcji.
Poznawanie ogólnej składni — funkcje
Widziałeś już kilka przykładów funkcji ogólnych, ale ważne jest, aby pamiętać, że funkcja ogólna może akceptować więcej niż jeden parametr typu ogólnego, podobnie jak zmienne. Możesz wybrać jeden, dwa, trzy lub dowolną liczbę typów, oddzielonych przecinkami (znowu, tak jak argumenty wejściowe).
Ta funkcja akceptuje trzy typy danych wejściowych i losowo zwraca jeden z nich:
function randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; } // Calling the function. // `value` has type `string | number | IAircraft` const value = randomValue< string, number, IAircraft >( myString, myNumber, myAircraft );
Widać również, że składnia różni się nieco w zależności od tego, czy używamy funkcji ES5, czy funkcji strzałkowej, ale obie deklarują parametry typu w sygnaturze:
const randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; }
Pamiętaj, że nie ma „ograniczenia unikatowości” narzuconego typom — możesz przekazać dowolną kombinację, na przykład dwa string
s i number
. Dodatkowo, podobnie jak argumenty wejściowe znajdują się w „zakresie” treści funkcji, tak samo są z parametrami typu ogólnego. Pierwszy przykład pokazuje, że mamy pełny dostęp do T
, U
i V
z ciała funkcji i użyliśmy ich do zadeklarowania lokalnej trójki.
Możesz sobie wyobrazić, że te generyki działają w pewnym „kontekście” lub w określonym „życiu”, a to zależy od tego, gdzie są zadeklarowane. Ogólne w funkcjach znajdują się w zakresie w obrębie sygnatury i treści funkcji (oraz zamknięć utworzonych przez funkcje zagnieżdżone), podczas gdy ogólne zadeklarowane w klasie lub interfejsie lub aliasie typu znajdują się w zakresie dla wszystkich elementów członkowskich klasy lub interfejsu lub aliasu typu.
Pojęcie generyków w funkcjach nie ogranicza się do „wolnych funkcji” lub „funkcji pływających” (funkcje nieprzyłączone do obiektu lub klasy, termin C++), ale mogą być również używane w funkcjach dołączonych do innych struktur.
Możemy umieścić tę randomValue
w klasie i możemy ją nazwać tak samo:
class Utils { public randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // ... } // Or, as an arrow function: public randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // ... } }
Możemy również umieścić definicję w interfejsie:
interface IUtils { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
Lub w ramach aliasu typu:
type Utils = { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
Tak jak poprzednio, te parametry typu ogólnego znajdują się „w zakresie” dla tej konkretnej funkcji — nie są klasą, interfejsem ani typem dla całego aliasu. Żyją tylko w ramach określonej funkcji, w której są określone. Aby udostępnić typ ogólny wszystkim członkom struktury, musisz dodać adnotację do samej nazwy struktury, jak zobaczymy poniżej.
Odkrywanie ogólnej składni — aliasy typów
W przypadku aliasów typów w nazwie aliasu używana jest ogólna składnia.
Na przykład jakaś funkcja „działania”, która przyjmuje wartość, prawdopodobnie mutuje tę wartość, ale zwraca void, można zapisać jako:
type Action<T> = (val: T) => void;
Uwaga : Powinno to być znane deweloperom języka C#, którzy rozumieją delegata Action<T>.
Lub funkcję zwrotną, która akceptuje zarówno błąd, jak i wartość, może być zadeklarowana jako taka:
type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }
Mając naszą wiedzę na temat generycznych funkcji, możemy pójść dalej i uczynić funkcję również generyczną w obiekcie API:
type CallbackFunction<T> = (err: Error, data: T) => void; const api = { // `T` is available for use within this function. get<T>(uri: string, cb: CallbackFunction<T>) { /// ... } }
Teraz mówimy, że funkcja get
akceptuje jakiś parametr typu ogólnego i cokolwiek to jest, CallbackFunction
go otrzymuje. Zasadniczo „przekazaliśmy” T
, które przechodzi w get
jako T
dla funkcji CallbackFunction
. Być może miałoby to większy sens, gdybyśmy zmienili nazwy:
type CallbackFunction<TData> = (err: Error, data: TData) => void; const api = { get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }
Poprzedzenie parametrów typu za pomocą T
jest jedynie konwencją, podobnie jak poprzedzenie interfejsów za pomocą I
lub zmiennych składowych za pomocą _
. Widać tutaj, że funkcja CallbackFunction
akceptuje pewien typ ( TData
), który reprezentuje ładunek danych dostępny dla funkcji, podczas gdy get
akceptuje parametr typu, który reprezentuje typ/kształt danych odpowiedzi HTTP ( TResponse
). Klient HTTP ( api
), podobnie jak Axios, używa tego, co TResponse
jest jako TData
dla funkcji CallbackFunction
. Dzięki temu obiekt wywołujący API może wybrać typ danych, który otrzyma z powrotem z API (przypuśćmy, że w innym miejscu potoku mamy oprogramowanie pośredniczące, które analizuje JSON w DTO).
Gdybyśmy chcieli zrobić to trochę dalej, moglibyśmy zmodyfikować parametry typu ogólnego w funkcji CallbackFunction
, aby zaakceptować również niestandardowy typ błędu:
type CallbackFunction<TData, TError> = (err: TError, data: TData) => void;
I tak jak możesz uczynić argumenty funkcji opcjonalnymi, możesz również z parametrami typu. W przypadku, gdy użytkownik nie poda typu błędu, domyślnie ustawimy go na konstruktor błędu:
type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;
Dzięki temu możemy teraz określić typ funkcji zwrotnej na wiele sposobów:
const apiOne = { // `Error` is used by default for `CallbackFunction`. get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }; apiOne.get<string>('uri', (err: Error, data: string) => { // ... }); const apiTwo = { // Override the default and use `HttpError` instead. get<TResponse>(uri: string, cb: CallbackFunction<TResponse, HttpError>) { // ... } }; apiTwo.get<string>('uri', (err: HttpError, data: string) => { // ... });
Ta idea parametrów domyślnych jest akceptowalna dla wszystkich funkcji, klas, interfejsów itd. — nie ogranicza się tylko do aliasów typów. We wszystkich przykładach, które widzieliśmy do tej pory, mogliśmy przypisać dowolny parametr typu do wartości domyślnej. Aliasy typów, podobnie jak funkcje, mogą przyjmować dowolną liczbę parametrów typu ogólnego.
Poznawanie ogólnej składni — interfejsy
Jak widać, do funkcji w interfejsie można podać parametr typu ogólnego:
interface IUselessFunctions { // Not generic printHelloWorld(); // Generic identity<T>(t: T): T; }
W tym przypadku T
żyje tylko dla funkcji identity
jako jej typu wejściowego i zwrotnego.
Możemy również udostępnić parametr typu wszystkim członkom interfejsu, podobnie jak w przypadku klas i aliasów typów, określając, że sam interfejs akceptuje rodzaj ogólny. Porozmawiamy o wzorcu repozytorium nieco później, kiedy będziemy omawiać bardziej złożone przypadki użycia dla generyków, więc jest w porządku, jeśli nigdy o nim nie słyszałeś. Wzorzec repozytorium pozwala nam wyabstrahować nasze przechowywanie danych, aby logika biznesowa była niezależna od trwałości. Jeśli chciałbyś stworzyć ogólny interfejs repozytorium, który działałby na nieznanych typach jednostek, możemy wpisać go w następujący sposób:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; removeById(id: string): Promise<void>; }
Uwaga : Istnieje wiele różnych myśli dotyczących repozytoriów, od definicji Martina Fowlera do definicji agregatu DDD. Próbuję jedynie pokazać przypadek użycia dla generyków, więc nie przejmuję się zbytnio poprawną implementacją. Zdecydowanie jest coś do powiedzenia na temat nieużywania ogólnych repozytoriów, ale porozmawiamy o tym później.
Jak widać tutaj, IRepository
to interfejs, który zawiera metody przechowywania i pobierania danych. Działa na pewnym parametrze typu ogólnego o nazwie T
, a T
jest używany jako dane wejściowe do add
i updateById
, a także wynik rozpoznawania obietnicy findById
.
Należy pamiętać, że istnieje bardzo duża różnica między akceptowaniem parametru typu ogólnego w nazwie interfejsu, a zezwoleniem każdej funkcji na akceptowanie parametru typu ogólnego. Pierwsza z nich, jak już tutaj zrobiliśmy, zapewnia, że każda funkcja w interfejsie działa na tym samym typie T
. Oznacza to, że w przypadku IRepository<User>
każda metoda, która używa T
w interfejsie, działa teraz na obiektach User
. Dzięki tej drugiej metodzie każda funkcja będzie mogła pracować z dowolnym typem, jakiego chce. Byłoby bardzo dziwne, gdyby można było dodawać tylko User
do repozytorium, ale móc na przykład otrzymywać z powrotem Policies
lub Orders
, co jest potencjalną sytuacją, w której znaleźlibyśmy się, gdybyśmy nie mogli wymusić, że typ jest jednolite we wszystkich metodach.
Dany interfejs może zawierać nie tylko typ udostępniony, ale także typy unikalne dla jego członków. Na przykład, gdybyśmy chcieli naśladować tablicę, moglibyśmy wpisać taki interfejs:
interface IArray<T> { forEach(func: (elem: T, index: number) => void): this; map<U>(func: (elem: T, index: number) => U): IArray<U>; }
W takim przypadku zarówno forEach
, jak i map
mają dostęp do T
z nazwy interfejsu. Jak wspomniano, możesz sobie wyobrazić, że T
jest w zasięgu wszystkich elementów interfejsu. Mimo to nic nie stoi na przeszkodzie, aby poszczególne funkcje w obrębie zaakceptowały również własne parametry typu. Funkcja map
działa, z U
. Teraz map
ma dostęp zarówno do T
, jak i U
. Musieliśmy nazwać parametr inną literą, na przykład U
, ponieważ T
jest już zajęte i nie chcemy kolizji nazw. Podobnie jak jego nazwa, map
„mapuje” elementy typu T
w tablicy na nowe elementy typu U
. Odwzorowuje T
na U
. Wartością zwracaną przez tę funkcję jest sam interfejs, działający teraz na nowym typie U
, dzięki czemu możemy nieco naśladować płynną składnię łańcuchową JavaScript dla tablic.
Zobaczymy przykład mocy Generic i interfejsów wkrótce, gdy zaimplementujemy wzorzec repozytorium i omówimy wstrzykiwanie zależności. Ponownie możemy zaakceptować dowolną liczbę parametrów ogólnych, a także wybrać jeden lub więcej parametrów domyślnych ułożonych na końcu interfejsu.
Poznawanie ogólnej składni — klasy
Podobnie jak możemy przekazać parametr typu ogólnego do aliasu typu, funkcji lub interfejsu, możemy również przekazać jeden lub więcej do klasy. Po wykonaniu tej czynności ten parametr typu będzie dostępny dla wszystkich członków tej klasy, a także dla rozszerzonych klas bazowych lub zaimplementowanych interfejsów.
Zbudujmy kolejną klasę kolekcji, ale nieco prostszą niż TypedList
, abyśmy mogli zobaczyć współdziałanie między typami ogólnymi, interfejsami i elementami członkowskimi. Nieco później zobaczymy przykład przekazywania typu do klasy bazowej i dziedziczenia interfejsu.
Nasza kolekcja będzie obsługiwać jedynie podstawowe funkcje CRUD oprócz map
i metody forEach
.
class Collection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): Collection<U> { return new Collection<U>(this.elements.map(func)); } } const stringCollection = new Collection<string>(); stringCollection.add('Hello, World!'); const numberCollection = new Collection<number>(); numberCollection.add(3.14159); const aircraftCollection = new Collection<IAircraft>(); aircraftCollection.add(myAircraft);
Porozmawiajmy o tym, co się tutaj dzieje. Klasa Collection
akceptuje jeden parametr typu ogólnego o nazwie T
. Ten typ staje się dostępny dla wszystkich członków klasy. Używamy go do definiowania prywatnej tablicy typu T[]
, którą moglibyśmy również oznaczyć w postaci Array<T>
(Patrz? Generics ponownie dla normalnego typowania tablicy TS). Co więcej, większość funkcji członkowskich wykorzystuje ten T
w pewien sposób, na przykład kontrolując typy, które są dodawane i usuwane lub sprawdzając, czy kolekcja zawiera element.
Wreszcie, jak widzieliśmy wcześniej, metoda map
wymaga własnego parametru typu ogólnego. We need to define in the signature of map
that some type T
is mapped to some type U
through a callback function, thus we need a U
. That U
is unique to that function in particular, which means we could have another function in that class that also accepts some type named U
, and that'd be fine, because those types are only “in scope” for their functions and not shared across them, thus there are no naming collisions. What we can't do is have another function that accepts a generic parameter named T
, for that'd conflict with the T
from the class signature.
You can see that when we call the constructor, we pass in the type we want to work with (that is, what type each element of the internal array will be). In the calling code at the bottom of the example, we work with string
s, number
s, and IAircraft
s.
How could we make this work with an interface? What if we have different collection interfaces that we might want to swap out or inject into calling code? To get that level of reduced coupling (low coupling and high cohesion is what we should always aim for), we'll need to depend on an abstraction. Generally, that abstraction will be an interface, but it could also be an abstract class.
Our collection interface will need to be generic, so let's define it:
interface ICollection<T> { add(t: T): void; contains(t: T): boolean; remove(t: T): void; forEach(func: (elem: T, index: number) => void): void; map<U>(func: (elem: T, index: number) => U): ICollection<U>; }
Now, let's suppose we have different kinds of collections. We could have an in-memory collection, one that stores data on disk, one that uses a database, and so on. By having an interface, the dependent code can depend upon the abstraction, permitting us to swap out different implementations without affecting the existing code. Here is the in-memory collection.
class InMemoryCollection<T> implements ICollection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): ICollection<U> { return new InMemoryCollection<U>(this.elements.map(func)); } }
The interface describes the public-facing methods and properties that our class is required to implement, expecting you to pass in a concrete type that those methods will operate upon. However, at the time of defining the class, we still don't know what type the API caller will wish to use. Thus, we make the class generic too — that is, InMemoryCollection
expects to receive some generic type T
, and whatever it is, it's immediately passed to the interface, and the interface methods are implemented using that type.
Calling code can now depend on the interface:
// Using type annotation to be explicit for the purposes of the // tutorial. const userCollection: ICollection<User> = new InMemoryCollection<User>(); function manageUsers(userCollection: ICollection<User>) { userCollection.add(new User()); }
With this, any kind of collection can be passed into the manageUsers
function as long as it satisfies the interface. This is useful for testing scenarios — rather than dealing with over-the-top mocking libraries, in unit and integration test scenarios, I can replace my SqlServerCollection<T>
(for example) with InMemoryCollection<T>
instead and perform state-based assertions instead of interaction-based assertions. This setup makes my tests agnostic to implementation details, which means they are, in turn, less likely to break when refactoring the SUT.
At this point, we should have worked up to the point where we can understand that first TypedList<T>
example. Here it is again:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
The class itself accepts a generic type parameter named T
, and all members of the class are provided access to it. The instance method select
and the two static methods from
and create
, which are factories, accept their own generic type parameter named U
.
The create
static method permits the construction of a list with optional seed data. It accepts some type named U
to be the type of every element in the list as well as an optional array of U
elements, typed as U[]
. When it calls the list's constructor with new
, it passes that type U
as the generic parameter to TypedList
. This creates a new list where the type of every element is U
. It is exactly the same as how we could call the constructor of our collection class earlier with new Collection<SomeType>()
. The only difference is that the generic type is now passing through the create
method rather than being provided and used at the top-level.
I want to make sure this is really, really clear. I've stated a few times that we can think about passing around types in a similar way that we do variables. It should already be quite intuitive that we can pass a variable through as many layers of indirection as we please. Forget generics and types for a moment and think about an example of the form:
class MyClass { private constructor (t: number) {} public static create(u: number) { return new MyClass(u); } } const myClass = MyClass.create(2.17);
This is very similar to what is happening with the more-involved example, the difference being that we're working on generic type parameters, not variables. Here, 2.17
becomes the u
in create
, which ultimately becomes the t
in the private constructor.
In the case of generics:
class MyClass<T> { private constructor () {} public static create<U>() { return new MyClass<U>(); } } const myClass = MyClass.create<number>();
The U
passed to create
is ultimately passed in as the T
for MyClass<T>
. When calling create
, we provided number
as U
, thus now U = number
. We put that U
(which, again, is just number
) into the T
for MyClass<T>
, so that MyClass<T>
effectively becomes MyClass<number>
. The benefit of generics is that we're opened up to be able to work with types in this abstract and high-level fashion, similar to how we can normal variables.
The from
method constructs a new list that operates on an array of elements of type U
. It uses that type U
, just like create
, to construct a new instance of the TypedList
class, now passing in that type U
for T
.
The where
instance method performs a filtering operation based upon a predicate function. There's no mapping happening, thus the types of all elements remain the same throughout. The filter
method available on JavaScript's array returns a new array of values, which we pass into the from
method. So, to be clear, after we filter out the values that don't satisfy the predicate function, we get an array back containing the elements that do. All those elements are still of type T
, which is the original type that the caller passed to create
when the list was first created. Those filtered elements get given to the from
method, which in turn creates a new list containing all those values, still using that original type T
. The reason why we return a new instance of the TypedList
class is to be able to chain new method calls onto the return result. This adds an element of “immutability” to our list.
Hopefully, this all provides you with a more intuitive example of generics in practice, and their reason for existence. Next, we'll look at a few of the more advanced topics.
Generic Type Inference
Throughout this article, in all cases where we've used generics, we've explicitly defined the type we're operating on. It's important to note that in most cases, we do not have to explicitly define the type parameter we pass in, for TypeScript can infer the type based on usage.
If I have some function that returns a random number, and I pass the return result of that function to identity
from earlier without specifying the type parameter, it will be inferred automatically as number
:
// `value` is inferred as type `number`. const value = identity(getRandomNumber());
Aby zademonstrować wnioskowanie o typie, usunąłem wcześniej wszystkie technicznie nieistotne adnotacje typu z naszej struktury TypedList
, a na poniższych obrazkach widać, że TSC nadal poprawnie wnioskuje wszystkie typy:
TypedList
bez dodatkowych deklaracji typu:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T) { this.values.push(value); } public where(predicate: (value: T) => boolean) { return TypedList.from(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U) { return TypedList.from(this.values.map(selector)); } public toArray() { return this.values; } public static from<U>(values: U[]) { // Perhaps we perform some logic here. // ... return new TypedList(values); } public static create<U>(values?: U[]) { return new TypedList(values ?? []); } // Other collection functions. // .. }
Bazując na wartościach zwracanych przez funkcję i typach danych wejściowych przekazanych from
i konstruktorze, TSC rozumie wszystkie informacje o typie. Na poniższym obrazku połączyłem wiele obrazów, które pokazują rozszerzenie języka Code TypeScript programu Visual Studio (a tym samym kompilator) wnioskujące wszystkie typy:
Ograniczenia ogólne
Czasami chcemy umieścić ograniczenie wokół typu ogólnego. Być może nie możemy obsługiwać wszystkich istniejących typów, ale możemy obsługiwać ich podzbiór. Powiedzmy, że chcemy zbudować funkcję, która zwraca długość jakiejś kolekcji. Jak widać powyżej, możemy mieć wiele różnych typów tablic/kolekcji, od domyślnej Array
JavaScript po nasze niestandardowe. W jaki sposób informujemy naszą funkcję, że jakiś typ ogólny ma dołączoną właściwość length
? Podobnie, jak ograniczyć konkretne typy, które przekazujemy do funkcji, do tych, które zawierają potrzebne nam dane? Przykład taki jak ten na przykład nie zadziała:
function getLength<T>(collection: T): number { // Error. TS does not know that a type T contains a `length` property. return collection.length; }
Odpowiedzią jest wykorzystanie ograniczeń ogólnych. Możemy zdefiniować interfejs opisujący potrzebne nam właściwości:
interface IHasLength { length: number; }
Teraz, definiując naszą funkcję generyczną, możemy ograniczyć typ ogólny jako taki, który rozszerza ten interfejs:
function getLength<T extends IHasLength>(collection: T): number { // Restricting `collection` to be a type that contains // everything within the `IHasLength` interface. return collection.length; }
Przykłady ze świata rzeczywistego
W następnych kilku sekcjach omówimy kilka rzeczywistych przykładów generyków, które tworzą bardziej elegancki i łatwy do uzasadnienia kod. Widzieliśmy wiele trywialnych przykładów, ale chcę omówić kilka podejść do obsługi błędów, wzorców dostępu do danych i stanu/props frontonu React.
Przykłady ze świata rzeczywistego — podejścia do obsługi błędów
JavaScript zawiera pierwszorzędny mechanizm obsługi błędów, podobnie jak większość języków programowania — try
/ catch
. Mimo to nie jestem wielkim fanem tego, jak wygląda w użyciu. Nie oznacza to, że nie używam mechanizmu, używam go, ale staram się to ukryć tak bardzo, jak tylko mogę. Abstrahując try
/ catch
away, mogę również ponownie wykorzystać logikę obsługi błędów w operacjach, które mogą się nie powieść.
Załóżmy, że budujemy warstwę dostępu do danych. Jest to warstwa aplikacji, która otacza logikę trwałości dotyczącą metody przechowywania danych. Jeśli wykonujemy operacje na bazie danych i jeśli ta baza danych jest używana w sieci, prawdopodobnie wystąpią określone błędy specyficzne dla bazy danych i przejściowe wyjątki. Jednym z powodów posiadania dedykowanej warstwy dostępu do danych jest oderwanie bazy danych od logiki biznesowej. Z tego powodu nie możemy mieć takich błędów specyficznych dla DB wyrzucanych na stos i poza tę warstwę. Najpierw musimy je owinąć.
Spójrzmy na typową implementację, która używałaby try
/ catch
:
async function queryUser(userID: string): Promise<User> { try { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
Przełączanie na true
jest jedynie metodą pozwalającą na użycie instrukcji switch case
dla mojej logiki sprawdzania błędów, w przeciwieństwie do konieczności deklarowania łańcucha if/else if — sztuczka, którą po raz pierwszy usłyszałem od @Jeffijoe.
Jeśli mamy wiele takich funkcji, musimy powtórzyć logikę owijania błędów, co jest bardzo złą praktyką. Wygląda całkiem nieźle jak na jedną funkcję, ale z wieloma będzie koszmarem. Aby wyabstrahować tę logikę, możemy opakować ją w niestandardową funkcję obsługi błędów, która przejdzie przez wynik, ale przechwyci i zapakuje wszelkie błędy, jeśli zostaną zgłoszone:
async function withErrorHandling<T>( dalOperation: () => Promise<T> ): Promise<T> { try { // This unwraps the promise and returns the type `T`. return await dalOperation(); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
Aby upewnić się, że ma to sens, mamy funkcję zatytułowaną withErrorHandling
, która akceptuje pewien parametr typu ogólnego T
. Ten T
reprezentuje typ wartości pomyślnego rozwiązania obietnicy, której oczekujemy zwróconej przez funkcję zwrotną dalOperation
. Zwykle, ponieważ po prostu zwracamy wynik powrotu funkcji asynchronicznej dalOperation
, nie musielibyśmy na to await
, ponieważ spowodowałoby to zawinięcie funkcji w drugą nieistotną obietnicę, a await
moglibyśmy pozostawić kodowi wywołującemu. W tym przypadku musimy wyłapać ewentualne błędy, dlatego wymagane jest await
.
Możemy teraz użyć tej funkcji, aby zawinąć nasze wcześniejsze operacje DAL:
async function queryUser(userID: string) { return withErrorHandling<User>(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); }); }
I tam idziemy. Mamy bezpieczną dla typu i bezpieczną przed błędami funkcję zapytania użytkownika.
Ponadto, jak widzieliśmy wcześniej, jeśli kompilator TypeScript ma wystarczającą ilość informacji, aby niejawnie wywnioskować typy, nie trzeba ich jawnie przekazywać. W tym przypadku TSC wie, że wynik zwracany przez funkcję jest tym, czym jest typ ogólny. Tak więc, jeśli mapper.toDomain(user)
zwrócił typ User
, nie musisz w ogóle przekazywać tego typu:
async function queryUser(userID: string) { return withErrorHandling(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(user); }); }
Inne podejście do obsługi błędów, które lubię, to typy monadyczne. Either Monad to algebraiczny typ danych w postaci Either<T, U>
, gdzie T
może reprezentować typ błędu, a U
może reprezentować typ niepowodzenia. Używanie typów monadycznych nawiązuje do programowania funkcjonalnego, a główną korzyścią jest to, że błędy stają się bezpieczne dla typu — normalna sygnatura funkcji nie mówi wywołującemu API niczego o błędach, które może generować ta funkcja. Załóżmy, że zgłaszamy błąd NotFound
z wnętrza queryUser
. Sygnatura queryUser(userID: string): Promise<User>
nic nam o tym nie mówi. Ale podpis taki jak queryUser(userID: string): Promise<Either<NotFound, User>>
absolutnie to robi. W tym artykule nie będę wyjaśniał, jak działają monady, takie jak Either Monada, ponieważ mogą być dość złożone i istnieje wiele metod, które należy uznać za monadyczne, takie jak mapowanie/wiązanie. Jeśli chcesz dowiedzieć się o nich więcej, polecam dwie prelekcje NDC Scotta Wlaschina, tu i tutaj, a także prelekcję Daniela Chambera tutaj. Ta strona, jak również te wpisy na blogu, również mogą być przydatne.
Przykłady ze świata rzeczywistego — wzorzec repozytorium
Rzućmy okiem na inny przypadek użycia, w którym generyczne mogą być pomocne. Większość systemów zaplecza musi w jakiś sposób komunikować się z bazą danych — może to być relacyjna baza danych, taka jak PostgreSQL, baza danych dokumentów, taka jak MongoDB, a może nawet baza danych wykresów, taka jak Neo4j.
Ponieważ jako programiści powinniśmy dążyć do nisko sprzężonych i wysoce spójnych projektów, uczciwym argumentem byłoby rozważenie konsekwencji migracji systemów baz danych. Byłoby również uczciwe wziąć pod uwagę, że różne potrzeby dostępu do danych mogą preferować różne podejścia do dostępu do danych (zaczyna to trochę wchodzić w CQRS, co jest wzorcem do rozdzielania odczytów i zapisów. Zobacz post Martina Fowlera i listę MSDN, jeśli chcesz aby dowiedzieć się więcej. Książki „Implementing Domain Driven Design” autorstwa Vaughna Vernona oraz „Patterns, Principles and Practices of Domain-Driven Design” autorstwa Scotta Milleta są również dobrymi lekturami). Powinniśmy również rozważyć testowanie automatyczne. Większość samouczków wyjaśniających budowę systemów zaplecza za pomocą Node.js łączy kod dostępu do danych z logiką biznesową i routingiem. Oznacza to, że mają tendencję do używania MongoDB z Mongoose ODM, stosując podejście Active Record i nie rozdzielając obaw. Takie techniki są niemile widziane w dużych aplikacjach; w momencie, gdy zdecydujesz się na migrację jednego systemu bazodanowego do innego lub zdasz sobie sprawę, że wolisz inne podejście do dostępu do danych, musisz wyrwać stary kod dostępu do danych, zastąpić go nowym kodem, i mam nadzieję, że po drodze nie pojawiły się żadne błędy w routingu i logice biznesowej.
Oczywiście, możesz argumentować, że testy jednostkowe i integracyjne zapobiegną regresji, ale jeśli te testy zostaną połączone i zależne od szczegółów implementacji, do których powinny być niezależne, prawdopodobnie również ulegną przerwaniu.
Typowym podejściem do rozwiązania tego problemu jest wzorzec repozytorium. Mówi, że wywołując kod, powinniśmy pozwolić naszej warstwie dostępu do danych naśladować zwykły zbiór obiektów lub jednostek domeny w pamięci. W ten sposób możemy pozwolić biznesowi kierować projektem, a nie bazą danych (modelem danych). W przypadku dużych aplikacji przydatny staje się wzorzec architektoniczny o nazwie Projektowanie oparte na domenie. Repozytoria, we wzorcu repozytorium, są komponentami, najczęściej klasami, które hermetyzują i przechowują wewnętrzną całą logikę dostępu do źródeł danych. Dzięki temu możemy scentralizować kod dostępu do danych w jednej warstwie, dzięki czemu można go łatwo przetestować i ponownie wykorzystać. Ponadto możemy umieścić pomiędzy nimi warstwę mapowania, co pozwala nam mapować modele domeny niezależne od bazy danych na serię mapowań tabel jeden-do-jednego. Każda funkcja dostępna w Repozytorium może opcjonalnie używać innej metody dostępu do danych, jeśli tak zdecydujesz.
Istnieje wiele różnych podejść i semantyki do repozytoriów, jednostek pracy, transakcji bazodanowych między tabelami i tak dalej. Ponieważ jest to artykuł o Generykach, nie chcę zbytnio wchodzić w chwasty, dlatego zilustruję tutaj prosty przykład, ale ważne jest, aby pamiętać, że różne aplikacje mają różne potrzeby. Repozytorium agregatów DDD byłoby na przykład zupełnie inne niż to, co tutaj robimy. Sposób, w jaki przedstawiam tutaj implementacje Repozytorium, nie jest taki, jak implementuję je w rzeczywistych projektach, ponieważ w użyciu jest wiele brakujących funkcji i mniej niż pożądane praktyki architektoniczne.
Załóżmy, że mamy Users
i Tasks
jako modele domeny. Mogą to być po prostu POTO — zwykłe stare obiekty TypeScript. Nie ma w nich wpisanej bazy danych, dlatego nie wywołałbyś na przykład User.save()
, tak jakbyś używał Mongoose. Korzystając ze wzorca repozytorium, możemy zachować użytkownika lub usunąć zadanie z naszej logiki biznesowej w następujący sposób:
// Querying the DB for a User by their ID. const user: User = await userRepository.findById(userID); // Deleting a Task by its ID. await taskRepository.deleteById(taskID); // Deleting a Task by its owner's ID. await taskRepository.deleteByUserId(userID);
Wyraźnie widać, jak cała niechlujna i przejściowa logika dostępu do danych jest ukryta za tą fasadą/abstrakcją repozytorium, co sprawia, że logika biznesowa jest agnostyczna wobec problemów związanych z trwałością.
Zacznijmy od zbudowania kilku prostych modeli domen. Są to modele, z którymi będzie współdziałać kod aplikacji. Są tutaj anemicy, ale trzymaliby się własnej logiki, aby zadowolić niezmienne biznesowe w świecie rzeczywistym, to znaczy nie byłyby zwykłymi torbami z danymi.
interface IHasIdentity { id: string; } class User implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _username: string ) {} public get id() { return this._id; } public get username() { return this._username; } } class Task implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _title: string ) {} public get id() { return this._id; } public get title() { return this._title; } }
Za chwilę zobaczysz, dlaczego wyodrębniamy informacje o typowaniu tożsamości do interfejsu. Ten sposób definiowania modeli domenowych i przekazywania wszystkiego przez konstruktor nie jest taki, jak bym to zrobił w prawdziwym świecie. Ponadto poleganie na abstrakcyjnej klasie modelu domeny byłoby bardziej preferowane niż interfejs, aby uzyskać implementację id
za darmo.
W przypadku repozytorium, ponieważ w tym przypadku spodziewamy się, że wiele takich samych mechanizmów trwałości będzie współużytkowanych w różnych modelach domen, możemy wyabstrahować nasze metody repozytorium do ogólnego interfejsu:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; deleteById(id: string): Promise<void>; existsById(id: string): Promise<boolean>; }
Moglibyśmy pójść dalej i stworzyć repozytorium ogólne, aby zmniejszyć duplikację. Dla zwięzłości, nie zrobię tego tutaj i powinienem zauważyć, że interfejsy Repozytorium Ogólnego, takie jak ten i Repozytoria Ogólne, generalnie nie są mile widziane, ponieważ możesz mieć pewne jednostki, które są tylko do odczytu lub zapisu -tylko, lub których nie można usunąć, lub podobne. To zależy od aplikacji. Ponadto nie mamy pojęcia o „jednostce pracy” w celu współdzielenia transakcji między tabelami, funkcji, którą zaimplementowałbym w prawdziwym świecie, ale znowu, ponieważ jest to małe demo, nie chcesz być zbyt techniczny.
Zacznijmy od implementacji naszego UserRepository
. Zdefiniuję interfejs IUserRepository
, który przechowuje metody specyficzne dla użytkowników, umożliwiając w ten sposób wywoływanie kodu zależnego od tej abstrakcji, gdy wstrzykujemy zależności w konkretnych implementacjach:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository implements IUserRepository { // There are 6 methods to implement here all using the // concrete type of `User` - Five from IRepository<User> // and the one above. }
Repozytorium zadań byłoby podobne, ale zawierałoby różne metody, zgodnie z potrzebami aplikacji.
Tutaj definiujemy interfejs, który rozszerza interfejs ogólny, dlatego musimy przekazać konkretny typ, nad którym pracujemy. Jak widać z obu interfejsów, mamy wrażenie, że wysyłamy te modele domen POTO i je usuwamy. Kod wywołujący nie ma pojęcia, na czym polega mechanizm utrwalania, io to właśnie chodzi.
Kolejną kwestią do rozważenia jest to, że w zależności od wybranej przez nas metody dostępu do danych, będziemy musieli poradzić sobie z błędami specyficznymi dla bazy danych. Moglibyśmy na przykład umieścić Mongoose lub Knex Query Builder za tym repozytorium, a w takim przypadku będziemy musieli poradzić sobie z tymi konkretnymi błędami — nie chcemy, aby wpłynęły one na logikę biznesową, ponieważ złamałoby to rozdział obaw i wprowadzić większy stopień sprzężenia.
Zdefiniujmy repozytorium bazowe dla metod dostępu do danych, których chcemy użyć, a które mogą za nas obsłużyć błędy:
class BaseKnexRepository { // A constructor. /** * Wraps a likely to fail database operation within a function that handles errors by catching * them and wrapping them in a domain-safe error. * * @param dalOp The operation to perform upon the database. */ public async withErrorHandling<T>(dalOp: () => Promise<T>) { try { return await dalOp(); } catch (e) { // Use a proper logger: console.error(e); // Handle errors properly here. } } }
Teraz możemy rozszerzyć tę klasę bazową w repozytorium i uzyskać dostęp do tej metody Generic:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository extends BaseKnexRepository implements IUserRepository { private readonly dbContext: Knex | Knex.Transaction; public constructor (private knexInstance: Knex | Knex.Transaction) { super(); this.dbContext = knexInstance; } // Example `findById` implementation: public async findById(id: string): Promise<User> { return this.withErrorHandling<User>(async () => { const dbUser = await this.dbContext<DbUser>() .select() .where({ user_id: id }) .first(); // Maps type DbUser to User return mapper.toDomain(dbUser); }); } // There are 5 methods to implement here all using the // concrete type of `User`. }
Zauważ, że nasza funkcja pobiera DbUser
z bazy danych i mapuje go na model domeny User
przed zwróceniem. Jest to wzorzec Data Mapper, który ma kluczowe znaczenie dla zachowania separacji obaw. DbUser
to mapowanie jeden-do-jednego na tabelę bazy danych — jest to model danych, na którym działa repozytorium — i dlatego jest wysoce zależny od używanej technologii przechowywania danych. Z tego powodu DbUser
nigdy nie opuszczą repozytorium i zostaną zmapowane do modelu domeny User
przed zwróceniem. Nie pokazywałem implementacji DbUser
, ale może to być po prostu prosta klasa lub interfejs.
Jak dotąd, korzystając ze wzorca repozytorium, opartego na Generics, udało nam się wyodrębnić problemy dotyczące dostępu do danych na małe jednostki, a także zachować bezpieczeństwo typów i możliwość ponownego użycia.
Na koniec, dla celów testów jednostkowych i integracyjnych, załóżmy, że zachowamy implementację w repozytorium w pamięci, aby w środowisku testowym móc wstrzyknąć to repozytorium i wykonywać na dysku asercje oparte na stanie zamiast naśladować za pomocą kpiące ramy. Ta metoda zmusza wszystko do polegania na publicznych interfejsach, a nie pozwala na powiązanie testów ze szczegółami implementacji. Ponieważ jedyne różnice między każdym repozytorium to metody, które wybierają do dodania w interfejsie ISomethingRepository
, możemy zbudować ogólne repozytorium w pamięci i rozszerzyć je w ramach implementacji specyficznych dla typu:
class InMemoryRepository<T extends IHasIdentity> implements IRepository<T> { protected entities: T[] = []; public findById(id: string): Promise<T> { const entityOrNone = this.entities.find(entity => entity.id === id); return entityOrNone ? Promise.resolve(entityOrNone) : Promise.reject(new NotFound()); } // Implement the rest of the IRepository<T> methods here. }
Celem tej klasy bazowej jest wykonanie całej logiki obsługi pamięci masowej w pamięci, abyśmy nie musieli jej duplikować w repozytoriach testowych w pamięci. Ze względu na metody takie jak findById
, to repozytorium musi rozumieć, że jednostki zawierają pole id
, dlatego konieczne jest ogólne ograniczenie interfejsu IHasIdentity
. Widzieliśmy ten interfejs już wcześniej — zaimplementowały go nasze modele domen.
Dzięki temu, jeśli chodzi o budowanie repozytorium użytkowników lub zadań w pamięci, możemy po prostu rozszerzyć tę klasę i uzyskać większość metod zaimplementowanych automatycznie:
class InMemoryUserRepository extends InMemoryRepository<User> { public async existsByUsername(username: string): Promise<boolean> { const userOrNone = this.entities.find(entity => entity.username === username); return Boolean(userOrNone); // or, return !!userOrNone; } // And that's it here. InMemoryRepository implements the rest. }
Tutaj nasze InMemoryRepository
musi wiedzieć, że encje mają pola takie jak id
i username
, dlatego przekazujemy User
jako parametr generyczny. User
implementuje już IHasIdentity
, więc ogólne ograniczenie jest spełnione i stwierdzamy również, że mamy również właściwość username
.
Teraz, gdy chcemy korzystać z tych repozytoriów z warstwy logiki biznesowej, jest to dość proste:
class UserService { public constructor ( private readonly userRepository: IUserRepository, private readonly emailService: IEmailService ) {} public async createUser(dto: ICreateUserDTO) { // Validate the DTO: // ... // Create a User Domain Model from the DTO const user = userFactory(dto); // Persist the Entity await this.userRepository.add(user); // Send a welcome email await this.emailService.sendWelcomeEmail(user); } }
(Zauważ, że w prawdziwej aplikacji prawdopodobnie przenieślibyśmy wywołanie do emailService
do kolejki zadań, aby nie dodawać opóźnień do żądania i w nadziei, że będziemy w stanie wykonać idempotentne ponawianie prób w przypadku niepowodzeń (— nie to, że wysyłanie wiadomości e-mail jest szczególnie idempotent na pierwszym miejscu).Ponadto przekazywanie całego obiektu użytkownika do usługi jest również wątpliwe.Inną kwestią, na którą należy zwrócić uwagę, jest to, że możemy znaleźć się w sytuacji, w której serwer ulega awarii po utrwaleniu użytkownika, ale przed wysłaniem wiadomości e-mail Istnieją wzorce łagodzenia, aby temu zapobiec, ale dla celów pragmatyzmu interwencja człowieka z odpowiednim rejestrowaniem prawdopodobnie będzie działać dobrze).
I gotowe — korzystając ze wzorca repozytorium z mocą Generics, całkowicie oddzieliliśmy nasz DAL od naszego BLL i udało nam się połączyć z naszym repozytorium w sposób bezpieczny dla typów. Opracowaliśmy również sposób szybkiego tworzenia repozytoriów w pamięci równie bezpiecznych dla typów na potrzeby testów jednostkowych i integracyjnych, co pozwala na prawdziwe testy czarnoskrzynkowe i niezależne od implementacji. Nic z tego nie byłoby możliwe bez typów generycznych.
Jako zastrzeżenie, chcę jeszcze raz zauważyć, że w tej implementacji Repozytorium wiele brakuje. Chciałem zachować prosty przykład, ponieważ skupiałem się na wykorzystaniu generyków, dlatego nie zajmowałem się duplikacją ani nie martwię się o transakcje. Przyzwoite implementacje repozytorium wymagałyby samodzielnego wyjaśnienia w pełni i poprawnie, a szczegóły implementacji zmieniają się w zależności od tego, czy robisz architekturę N-Tier, czy DDD. Oznacza to, że jeśli chcesz użyć wzorca repozytorium, nie powinieneś traktować mojej implementacji tutaj jako najlepszej praktyki.
Przykłady ze świata rzeczywistego — stan reakcji i rekwizyty
State, ref i pozostałe punkty zaczepienia dla komponentów funkcjonalnych React są również ogólne. Jeśli mam interfejs zawierający właściwości Task
s i chcę przechowywać ich kolekcję w komponencie React, mogę to zrobić w następujący sposób:
import React, { useState } from 'react'; export const MyComponent: React.FC = () => { // An empty array of tasks as the initial state: const [tasks, setTasks] = useState<Task[]>([]); // A counter: // Notice, type of `number` is inferred automatically. const [counter, setCounter] = useState(0); return ( <div> <h3>Counter Value: {counter}</h3> <ul> { tasks.map(task => ( <li key={task.id}> <TaskItem {...task} /> </li> )) } </ul> </div> ); };
Dodatkowo, jeśli chcemy przekazać szereg właściwości do naszej funkcji, możemy użyć ogólnego typu props
React.FC<T>
i uzyskać dostęp do właściwości:
import React from 'react'; interface IProps { id: string; title: string; description: string; } export const TaskItem: React.FC<IProps> = (props) => { return ( <div> <h3>{props.title}</h3> <p>{props.description}</p> </div> ); };
Typ props
jest automatycznie określany jako IProps
przez kompilator TS.
Wniosek
W tym artykule zobaczyliśmy wiele różnych przykładów Generic i ich przypadków użycia, od prostych kolekcji, przez podejścia do obsługi błędów, po izolację warstwy dostępu do danych i tak dalej. Mówiąc najprościej, Generics pozwala nam budować struktury danych bez konieczności znajomości konkretnego czasu, w którym będą działać w czasie kompilacji. Miejmy nadzieję, że pomoże to nieco bardziej otworzyć temat, sprawić, że pojęcie Generics stanie się bardziej intuicyjne i ujawnić ich prawdziwą moc.