Ten artykuł jest częscią serii

Jeśli nie przeczytałeś jeszcze poprzednich artykułów, to zawsze możesz to nadrobić klikając w przycisk.

Miniatura artykułu

TypeScript - typy generyczne

15 minut

Skopiuj link

Data publikacji: 7/31/2023, 11:41:45

Ostatnia aktualizacja: 4/1/2024

Co to w ogóle jest?

Typy generyczne są często określane również jako dynamiczne. Oznacza to, że nie opisują jednej, konkretnej wartości. Zamiast tego można je zmieniać i dostosowywać do swoich potrzeb, co znacznie zwiększa ich reużywalność.

Trafnym porównaniem są tutaj funkcje. Im więcej argumentów możemy przekazać do danej funkcji, tym jest ona bardziej konfigurowalna i może być dostosowana do konkretnego zadania (oczywiście trzeba zachować umiar - przesadna liczba parametrów w funkcji znacznie zmniejsza czytelność kodu).

Podobnie jest w przypadku "generyków", one również przyjmują argumenty. Różnica polega na tym, że takim argumentem nie jest konkretna wartość, a inny typ. Dzięki temu możemy tworzyć typy, które mogą być wykorzystane w wielu miejscach, zamiast wielokrotnie pisać prawie ten sam kod, z małymi zmianami.

Drugie zastosowanie typów generycznych to tak zwane typy pomocnicze (utility types), czyli takie, które pomagają nam podczas tworzenia innych typów. TypeScript dostarcza kilka wbudowanych typów pomocniczych, a w tym artykule opisuję te najpopularniejsze.

Składnia i wbudowane typy

Jak wspomniałem TS oferuje kilka wbudowanych typów generycznych i to właśnie od nich zaczniemy poznawanie generyków.

Wszystkie oferowane przez TypeScript generyki, to typy pomocnicze - wykorzystując je możemy modyfikować inne, istniejące typy. Sami również będziemy tworzyć podobne rozwiązania, jednak na razie wykorzystamy te dostarczone przez twórców języka.

Nazwa każdego z nich zaczyna się od wielkiej litery (tak samo, jak aliasów, które tworzymy w TS), następnie używamy <>, a pomiędzy nimi podajemy typ lub alias. Po złożeniu tego w całość, wygląda to tak: TypGeneryczny<AliasTypLubTyp>. Właśnie dlatego wspomniałem wcześniej o analogii do funkcji - najpierw podajemy nazwę, następnie "wywołujemy" za pomocą nawiasów ostrych, a pomiędzy nimi podajemy "argument". Jeżeli porównamy wywołanie funkcji, do typu generycznego, to okaże się, że wygląda bardzo podobnie: nazwaFunkcji(argumenty).

Na tym etapie warto również wiedzieć, że typ generyczny może przyjąć więcej niż jeden argument.

NonNullable

Idealny typ na początek. Jego działanie jest proste do zrozumienia i świetnie przygotuje Cię do zrozumienia świata generyków.

Przyjmuje tylko jeden argument: unię typów i usuwa z niej typy null oraz undefined. Zobaczmy jak to wygląda w praktyce:

Oczywiście moglibyśmy napisać po prostu arg: string i efekt byłby ten sam. Nie chciałem przesadnie komplikować pierwszego przykładu, żeby skupić się na tym, co najważniejsze.

Readonly

sprawia, że przekazany do środka typ, staje się tylko do odczytu - nie można zmienić jego właściwości. Do środka najczęściej przekazujemy typ opisujący obiekt, lub tablicę.

Record

To jeden z najczęściej wykorzystywanych typów generycznych. Przyjmuje dwa argumenty i tworzy na ich podstawie nowy typ obiektowy. Pierwszy argument, to klucze obiektu, natomiast drugi, to wartości: Record<keys, value>. Jak to wygląda w praktyce?

Partial

Przyjmuje jeden argument - typ obiektowy i sprawia, że wszystkie jego właściwości stają się opcjonalne (nie muszą być zdefiniowane). W przykładzie wykorzystamy Record, żeby się z nim oswoić, a przy okazji zobaczyć nieco bardziej zaawansowane konstrukcje językowe.

Podczas pracy z tym typem należy pamiętać, że działa on tylko na pierwszy poziom - nie wpływa na zagnieżdżone typy obiektowe.

Required

To odwrotność Partial. Przyjmuje jeden argument (typ obiektowy) i sprawia, że wszystkie jego właściwości są wymagane. Podobnie jak Partial działa tylko na pierwszy poziom, a zagnieżdżone obiekty pozostają niezmienione.

Pick

Przyjmuje dwa argumenty: typ obietkowy, oraz klucz (lub klucze) zapisany w postaci literału lub unii literałów, a następnie tworzy nowy obiekt zawierający jedynie te klucze (oraz przypisane do nich właściwości), które podaliśmy wcześniej jako argument. Zapis wygląda podobnie do Record, a mianowicie: Pick<TypObiektowy, "klucz">. Wersji z unią literałów użyjemy, gdy chcemy przenieść do nowego obiektu więcej niż jeden klucz: Pick<TypObiektowy, "klucz1" | "klucz2">

Omit

Można go rozumieć jako odwrotność Pick. Przyjmuje dokładnie te same argumenty i również tworzy nowy typ obiektowy, ale zamiast przenosić wskazane klucze do nowego obiektu, pomija je. Innymi słowy, do nowego typu trafią wszystkie klucze poza tymi, które podaliśmy jako argument. Posłużymy się przykładem podobnym do tego z Pick:

Nie są to wszystkie wbudowane typy generyczne, pełną listę znajdziesz tutaj. Nie musisz się ich uczyć na pamięć, ale dobrze jest wiedzieć jakie możliwości masz do dyspozycji i móc je łatwo odnaleźć w razie potrzeby.

Wiesz już jak wygląda składnia i na czym polega podstawowe działanie generyków. Najwyższy czas napisać własne typy generyczne i przy okazji wykorzystać te, które poznałeś do tej pory.

Własne generyki

Musisz wiedzieć, że nie tylko typy mogą być generyczne, poza nimi generyczna może być także funkcja oraz klasa. Na początku skupimy się jednak wyłącznie na typach, a na samym końcu przejdziemy do funkcji i klas.

Generyczny alias typu wygląda następująco: type NazwaAliasu<Argument1, Argument2, ...>. Liczba argumentów jest dowolna, jednak podobnie jak w przypadku funkcji, tu również trzeba zachować rozsądek. Najlepiej ograniczyć się do maksymalnie 3 argumentów (najczęściej spotyka się jeden, lub dwa argumenty).

Zazwyczaj argumenty są nazywane pojedynczymi literami np. T, U lub K. Oczywiście nie musisz się trzymać tej konwencji, zalecam jednak stosowanie wielkich liter jako prefixów. Będzie to oczywisty dla innych znak, że jest to typ generyczny.

Czas napisać pierwszy, prosty generyk:

W miejsce argumentu można oczywiście wstawić inny alias typu - nie trzeba go tworzyć bezpośrednio w miejscu użycia.

Oczywiście przykład powyżej można również utworzyć w inny sposób (np. z wykorzystaniem operatora & oraz typu bazowego, który następnie rozszerzymy).

Generyczna funkcja

Równie często, co z generycznych aliasów, będziesz korzystać z generycznych funkcji. Czasami może się okazać, że utworzona funkcja powinna przyjąć dowolny typ, a następnie zwrócić ten sam typ, który przyjmuje (dla uproszczenia nie będę pisać kodu wewnątrz funkcji - skupimy się na typach). Oczywiście nie jest to jedyne zastosowanie, ale jest do względnie prosty przykład.

Jeżeli nie wiemy o generycznych funkcjach, to poprawne otypowanie może okazać się sporym problemem. Na początek spróbujmy z wykorzystaniem typu unknown, co może wydawać się dobrym pomysłem, ostatecznie przyjmujemy dowolny typ, prawda?

TypeScript nie jest w stanie domyślić się typu, który zostanie zwrócony z funkcji, dlatego uznaje, że zwracamy tą samą wartość, którą przyjmujemy - w tym przypadku unknown[]. W efekcie utraciliśmy typ number[] i od teraz używając zmiennej unknownArray, za każdym razem będziemy zmuszeni zastosować typeguard (artykuł na ten temat znajdziesz tutaj) i sprawdzić z jaką wartością pracujemy.

Rozwiązaniem tego problemu są oczywiście wspomniane wcześniej funkcje generyczne. Na pierwszy rzut oka ich zapis może wydawać się dziwny: function nazwaFunkcji<T>(arg: T) { ... }. Przeanalizujmy szybko, co dokładnie się tutaj dzieje:

  1. Deklarujemy funkcję i nadajemy jej nazwę

  2. W nawiasach ostrych umieszczamy nazwę generycznego parametru, lub parametrów: <Nazwa1, Nazwa2>. Nawiasy ostre zawsze umieszczamy przed nawiasami okrągłymi funkcji - dotyczy to również funkcji strzałkowych.

  3. Jako typ parametru o nazwie arg podajemy utworzony wcześniej generyczny parametr: arg: T, jest to ten sam zapis, co z wykorzystaniem zwykłego typu: arg: string

Do generycznego parametru, utworzonego wewnątrz nawiasów ostrych mamy dostęp jedynie wewnątrz danej funkcji generycznej. Możemy go użyć do otypowania parametrów, zwracanego typu, lub wykorzystać w ciele funkcji.

Zauważ, że nie otypowaliśmy jawnie zwracanego z funkcji typu, a mimo to TS nadal odczytuje go poprawnie. Dzieje się tak, ponieważ inferencja działa również w przypadku typów generycznych.

Pokażę Ci jeszcze jeden przykład z wykorzystaniem generycznego parametru (tym razem napiszemy kod w środku), a przy okazji jawnie otypujemy zwracany typ, zamiast polegać na inferencji.

Funkcja będzie przyjmować dwa argumenty - pierwszy z nich to string[], natomiast drugi to generyczna tablica z dowolnym typem T[]. Następnie na podstawie tych dwóch tablic będziemy tworzyć obiekt w którym kluczami będą elementy z pierwszej tablicy, a wartościami elementy z drugiej tablicy.

Jeszcze raz przeanalizujmy co się dokładnie zadziało:

  1. Tworzymy wyrażenie funkcyjne z użyciem funkcji strzałkowej

  2. Tuż przed listą parametrów (czyli przed nawiasami ( )) wstawiamy nawiasy ostre <>, a wewnątrz nich jeden generyczny parametr T

  3. Następnie typujemy parametry funkcji: keys jest tablicą zawierającą stringi, a values to również tablica, ale z niewiadomą wartością, używamy zatem utworzonego wcześniej generycznego parametru T.

  4. Kolejny krok to jawne zadeklarowanie typu, który zostanie zwrócony z funkcji, robimy to po liście parametrów (): Record<string, T>. Typ Record omówiliśmy wcześniej - pierwszym argumentem będzie po prostu typ string, natomiast jako drugi podamy po raz kolejny generczny parametr T. Oznacza to, że z funkcji zwrócimy obiekt, którego kluczami będą stringi, a wartości do nich przypisane będą dokładnie takie, jak T (czyli zawartość tablicy values).

W efekcie zwrócony z tej funkcji typ, zależy od tego, co do niej przekażemy.

Generyczna klasa

Kwestia generycznych klas wygląda bardzo podobnie do funkcji. Możemy utworzyć generyczne parametry, a następnie używać ich wewnątrz klasy (w polach, metodach i konstruktorze).

Składnia również wygląda podobnie - nadal używamy nawiasów ostrych, a wewnątrz nich podajemy parametry. Jednak tutaj nawiasy umieszczamy tuż po nazwie: NazwaKlasy<T, U>

Posłużymy się prostym przykładem, żeby nie skupiać się za bardzo na samym działaniu klasy.

Parametr użyliśmy w konstruktorze oraz w metodzie get do otypowania zwracanej wartości. Oczywiście, jeżeli zajdzie taka potrzeba, to klasa, podobnie jak funkcja, może przyjąć więcej parametrów generycznych.

Temat klas poruszę nieco szerzej w osobnym artykule.

Ograniczanie parametrów generycznych

Dosyć często zdarza się, że generyczny typ, funkcja, lub klasa powinny przyjmować jedynie pewien zakres parametrów generycznych. Domyślnie możemy przekazać dowolny typ, jednak w rzeczywistości rzadko mamy do czynienia z generykami, które mogą przyjąć zupełnie dowolną wartość i nadal działać poprawnie.

Świetnym przykładem może być Record, o którym wspomnieliśmy wcześniej. Jego pierwszym argumentem musi być string | number | symbol ponieważ tylko te wartości mogą być kluczem obiektu (pod spodem i tak wszystkie są konwertowane na `string`).

Jak osiągnąć taki efekt, a tym samym zawęzić możliwe do przekazania typy w przypadku własnych generyków?

Za pomocą słowa kluczowego extends możemy wskazać do jakiego zbioru typów powinien należeć przekazany argument. W praktyce wygląda to tak: T extends string | number - oznacza to, że parametr T musi być typu string lub number.

Za przykład posłuży nam funkcja, która przyjmuje dowolny obiekt który posiada właściwość name. Inne właściwości nie mają tu żadnego znaczenia, mogą istnieć lub nie, nasza funkcja nie jest tym zupełnie zainteresowana, ponieważ z nich nie korzysta.

Warto wiedzieć, że parametr generyczny może mieć również domyślny typ (tak samo, jak parametry funkcji mogą mieć domyślne wartości), który zostanie użyty w przypadku, gdy nie przekażemy żadnego typu jako argument. Staje się on wtedy opcjonalny i nie musi być przekazany.

Zapis wygląda identycznie jak w przypadku funkcji w JavaScript. T = string, lub T extends string | number = number, w przypadku zastosowania ograniczeń typu i domyślnego typu jednocześnie.

W ostatnim przypadku nie podajemy żadnego argumentu. Nie używamy też nawiasów ostrych - po prostu stosujemy typ generyczny jak zwykły alias.

Podsumowanie

Typy generyczne to potężne narzędzie, dzięki któremu można tworzyć bardzo skomplikowane konstrukcje oraz typy pomocnicze (w przyszłych artykułach także stworzymy własne typy pomocnicze - utility types).

TypeScript dostarcza również kilka wbudowanych typów generycznych, które mają za zadanie ułatwić nam codzienną pracę w tym języku. Koniecznie sprawdź pełną listę w oficjalnej dokumentacji, żeby wiedzieć, jakie możliwości masz do dyspozycji (nie musisz uczyć się ich działania na pamięć, to przyjdzie z czasem).

Dzisiaj omówiliśmy jedynie podstawy, jednak temat jest znacznie szerszy (prawdopodobnie jest to najbardziej rozbudowane zagadnienie w całym TS). W kolejnych artykułach będziemy stopniowo wykorzystywać zdobytą dzisiaj wiedzę, jednak zachęcam Cię do eksperymentowania w tym zakresie, bo tylko w ten sposób można naprawdę zrozumieć dany język programowania (i nie tylko programowania).

Avatar: Wojciech Rygorowicz

Software Engineer / Fullstack developer

Wojciech Rygorowicz

wojciech.rygorowicz@gmail.com

Podziel się na

Typy pomocnicze cheatsheet.pdf

Dodaj komentarz

Komentarze (0)

Brak komentarzy

Jeżeli zainteresował Cię ten artykuł koniecznie przeczytaj inne artykuły z tej serii