Czemu any odbiera przekazany typ?
Otypuj parametr jako any i TypeScript przestaje sprawdzać wszystko, co wychodzi z funkcji:
| |
Ty wiesz, że przekazałeś tablicę liczb. Kompilator nie wie, bo any mówi mu, żeby przestał patrzeć. n wraca jako any, więc wywołanie metody string na liczbie kompiluje się bez problemu i wyrzuca TypeError: n.toUpperCase is not a function przy uruchomieniu.
Generic nazywa typ, zamiast go odrzucać:
| |
T to parametr typu, placeholder wypełniany przy każdym wywołaniu. firstElement([1, 2, 3]) wypełnia T typem number, typ zwracany staje się number, a błędne wywołanie .toUpperCase() zawodzi w edytorze, nie na produkcji. Funkcję piszesz raz, a każdy typ elementu zachowuje swoją kontrolę.
W dalszej części przewodnika pokazujemy, jak używać generics bez komplikowania sygnatury bardziej, niż komplikowałby życie błąd, któremu mają zapobiegać.
Jak TypeScript wnioskuje parametr typu?
Nie napisałeś wyżej firstElement<number>([1, 2, 3]) i rzadko musisz. TypeScript porównuje argument z zadeklarowaną formą parametru (T[]) i rozwiązuje T. To ta sama inferencja, która typuje const x = 5 jako number, zastosowana do parametrów typu.
Gdy nie ma z czego wnioskować, T spada do unknown:
| |
Gdy wywołanie nie ma żadnej wartości, z której można wnioskować, podaj argument typu sam, w ostrych nawiasach.
Inferencja może też pójść nie tak, gdy dwa argumenty trafiają do tego samego T i są niezgodne. TypeScript wybiera jednego kandydata i zgłasza drugi argument jako problem, co może skończyć się błędem, który nazywa typ, jakiego nigdy nie napisałeś:
| |
Gdy generyczne wywołanie kończy się niejasnym błędem lub nieoczekiwanym typem, najedź w edytorze na wywołanie i zobacz, co zostało wywnioskowane dla T. Zwykle to wyjaśnia problem szybciej niż czytanie komunikatu.
Jak ograniczyć generic za pomocą extends i keyof?
Nieograniczony T może być czymkolwiek, więc w funkcji możesz zrobić tylko to, co obsługuje każdy typ, czyli niemal nic ponad przekazywanie wartości dalej. Większość realnych generycznych funkcji musi coś zakładać o T, a extends to sposób, żeby to powiedzieć:
| |
K extends keyof T ogranicza K do nazw właściwości obiektu, który przekazujesz, a T[K] odczytuje typ tej właściwości. Lista dopuszczalnych kluczy pochodzi z obiektu w miejscu wywołania, nie z unii wpisanej na sztywno. Literówka jak "pay" staje się błędem kompilacji, nie cichym undefined.
Ograniczenie może być też prostym kształtem:
| |
I stringi, i tablice mają length, więc obie przechodzą. Liczba nie, i wywołanie jest odrzucane, zanim ciało funkcji w ogóle się wykona. Ponieważ typem zwracanym jest T, a nie { length: number }, logLength("hello") nadal zwraca string.
Jak działają generyczne interfejsy i klasy?
Generyczny interfejs to kształt z parametrem typu:
| |
Generyczna klasa współdzieli swój parametr typu między wszystkimi polami i metodami:
| |
new Stack<number>() i new Stack<string>() są w pełni otypowane, a klasa istnieje tylko raz. Ten sam wzorzec pojawia się wszędzie, gdzie używasz dependency injection: piszesz Repository<T> raz, a potem wstrzykujesz Repository<User> i Repository<Order> tam, gdzie są potrzebne, zamiast trzymać osobną, prawie identyczną klasę na każdą jednostkę.
Wiele parametrów typu działa tak samo, i każdy może mieć własne ograniczenie:
| |
Dwa parametry to też sposób, w jaki typ Result<T, E> łączy wartość sukcesu z typem błędu. Jeśli zastanawiasz się, czy funkcje powinny rzucać wyjątki, czy zwracać błędy jako dane, exceptions vs wartości błędów omawia ten wybór.
Jak działają domyślne parametry typu?
Parametr typu może mieć wartość domyślną, podobnie jak parametr funkcji z wartością domyślną:
| |
Inferencja ma pierwszeństwo, gdy jest argument, z którego można wnioskować, tak jak w raw. Wartość domyślna działa, gdy nic nie wypełnia T, na przykład gdy piszesz ApiResponse jako anotację typu bez argumentu typu. To sprawia, że wartości domyślne są przydatne w wrapperach takich jak typy odpowiedzi i payloady zdarzeń, gdzie wielu miejscom wywołania nie zależy na dokładnym typie danych. Jako domyślny wybierz unknown, nie any: wciąż wymusza sprawdzenie, zanim ktoś odczyta data.
Kiedy użyć union type zamiast generic?
Zadaj sobie jedno pytanie, zanim dodasz <T>: czy typ zwracany zależy od tego, co przekazano? Jeśli funkcja zwraca ten sam typ niezależnie od wejścia, unia wystarczy, a generic nic nie dodaje:
| |
firstElement, getProperty i Stack<T> są inne. Każda z nich zwraca coś, czego typ zależy od tego, co weszło, i właśnie ten związek między wejściem a wyjściem daje generic. Jeśli możesz napisać funkcję z unią, nic nie tracąc, użyj unii.
Dodawanie parametrów typu, bo funkcja mogłaby być generyczna, kosztuje czytelność i nic nie daje. Helper z trzema parametrami typu, wywoływany tylko ze string i number, jest trudniejszy do czytania niż dwa przeciążenia. Dodaj <T>, gdy realny wywołujący potrzebuje, żeby typ przepłynął, i usuń je, gdy każde miejsce wywołania ustala ten sam konkretny typ.
Czemu nie można użyć T w czasie działania?
Parametry typu są usuwane w czasie kompilacji. JavaScript, który się wykonuje, nie ma w sobie żadnego T, więc nie możesz go skonstruować, sprawdzić instanceof T, ani rozgałęziać się na jego podstawie:
| |
Jeśli funkcja musi zbudować T lub sprawdzić go w czasie działania, przekaż prawdziwą wartość jako argument: konstruktor, schemat albo tag typu. Generics są kontraktem w czasie kompilacji, nie ochroną w czasie działania, więc dane przychodzące z zewnątrz programu (odpowiedź API, JSON.parse) wciąż wymagają walidacji, nawet gdy są otypowane jako ApiResponse<User>.
Aby zobaczyć każdy błąd z tego przewodnika na własne oczy, zainstaluj TypeScript za pomocą npm, Yarn lub pnpm (tego, co już używa twój projekt), wklej przykłady do pliku .ts i uruchom npx tsc --noEmit --strict. Potem otwórz najczęściej używane any w swoim kodzie i sprawdź, czy <T> pozwoliłoby typowi przepłynąć.