19 kwietnia 2023 9 min. czytania

TypeScript - typy generyczne

Typy generyczne nie istnieją w JavaScripcie, ale są jednym z podstawowych konceptów w TypeScripcie. Oferują to co najlepsze z obu światów: elastyczność i bezpieczeństwo typów.

Zaktualizowano: 19 kwietnia 2023
Joker na szczycie rozsypanych kart
Zdjęcie autorstwa Archana GS

Kontynuujmy naszą TypeScriptową serię. Dziś przyjrzymy się konceptowi, który jest dla mnie trochę niejasny - typy generyczne. Jeżeli znasz inne języki programowania takie jak C# lub Java, najprawdopodobniej zapoznałeś się z nimi. Ale istnieje spora szansa, że podobnie do mnie, skupiasz się głównie na języku JavaScript (i być może jest to dla ciebie również niejasne). Nie ma takiego konceptu w podstawowym języku, więc może powodować problemy. Ale jednocześnie jest do niezbędny koncept języka TypeScript. Dlatego spróbuję wyjaśnić typy generyczne (tobie i sobie) jakbyśmy byli ośmiolatkami.

Co to jest typ generyczny?

Zwykle, gdy pracujemy z typami, definiujemy je tak.

Definiowanie typów w języku TypeScriptTS
1let person: string
2person = 'Sam'
3
4let surname = 'Fisher'

Definiujesz zmienną, inicjalizujesz ją (lub nie), definiujesz typ lub pozwalasz TypeScriptowi go wywnioskować. I to tyle. Typ się nie zmienia. Po to używamy języka TypeScript, nie? Tak, ale czasem potrzebujemy więcej elastyczności. Jednakże nie chcemy powracać do dynamicznych typów JavaScriptu. Aby osiągnąć balans i bezpieczeństwo związane z typami nadchodzą one - typy generyczne.

Typ generyczny jest jak zmienna, ale dla typów. Zmienna może przyjmować różne wartości - typ generyczny może akceptować różne typy. Typ generyczny jest powiązany z innym typem i jest elastyczny w stosunku do niego. Co przez to rozumiem?

Wbudowane typy generyczne

Jeżeli przyjrzysz się bliżej, podstawowe typy, takie jak Array czy Promise, to generyki w języku TypeScript. W “waniliowym” JavaScripcie, tablice mogą przechowywać różne typy. Używając TS-a, chcemy być bardziej precyzyjni. Na przykład, definiując tablicę z łańcuchami znaków, dajemy TS-owi więcej informacji, a w zamian otrzymujemy lepsze podpowiedzi. Możemy zapisać to na dwa sposoby. Możemy użyć standardowej składni…

Podstawowe typy i typy generyczneTS
1let hobbies: string[] // Standardowa składnia.
2let hobbies: Array<string> // Inna składnia wykorzystująca typy generyczne.
3hobbies = ['sneaking']
4
5// TypeScript wnosukje, że jest to string i daje podpowiedzi.
6hobbies[0].split('')

…albo wykorzystać typy generyczne. Jest to tablica z ciągami znaków Jest to typ powiązany z innym typem. Promise to również typ generyczny. Działa z innymi typami, bo ostatecznie zwraca inne typy.

Generyczna funkcja

Alternatywna składnia dla podstawowych typów jest ok, ale napiszmy jakieś niestandardowe generyki. Zobaczmy te zmienne dla typów w akcji.

Generyczna funkcjaTS
1function merge<T, U>(a: T, b: U) {
2 return {
3 ...a,
4 ...b
5 }
6}
7
8merge<string, string>('Sam', 'Fisher')
9merge<string[], string[]>(['Sam'], ['Fisher'])
10merge({ name: 'Sam' }, { hobbies: ['sneaking'] })

Aby zdefiniować typy generyczne, potrzebujemy dodatkową parę (ostrych) nawiasów. Istnieje konwencja mówiąca, żeby zaczynać definiować typy generyczne przy pomocy liter alfabetu, zaczynając od litery “T”. Argument “a” jest typu “T”, a argument “b” jest typu “U”. Te typy nie są statyczne. Są dynamiczne - ustawione, gdy wywołujemy funkcję. TypeScript nadal może wywnioskować zwracaną wartość i zaoferować automatyczne uzupełnianie. Generyki pozwalają ci pracować z danymi w optymalny, TS-owy sposób. Przyjrzyj się powyższemu fragmentowi - możemy podać obiekty, tablice lub łańcuchy znaków. Funkcje generyczne pozwalają ci wypełniać konkretne typy dla różnych wywołań. “Czyli mogę też podać liczby?”. Tak i to jest problematyczne, bo używamy operatora spread.

Ograniczenia typów generycznych

Z pomocą przychodzą ograniczenia typów generycznych. Czasami chcemy elastyczności, ale z drugiej strony walidacji. Możesz ustawić pewne limity dla typów na których bazują typy generyczne. Słowo kluczowe extends służy do ustawiania takich ograniczeń.

Ograniczenia typów generycznychTS
1function merge<T extends object, U extends object>(a: T, b: U) {
2 return {
3 ...a,
4 ...b
5 }
6}
7
8// TypeScript error:
9// Argument of type 'string' is not assignable to parameter of type 'object'.
10merge('Sam', 'Fisher')

Teraz nasza funkcja przyjmuje tylko obiekty i unika błędów w czasie wykonywania programu. Pójdźmy nawet dalej z naszym przykładem. Powiedzmy, że w dalszej części kodu chcemy użyć jakiejś właściwości i chcemy mieć pewność, że będzie ona istniała. Możemy zdefiniować interfejsy i wykorzystać je z naszymi parametrami.

Generyczne interfejsyTS
1interface BasicInfo {
2 name: string
3}
4
5interface AdditionalInfo {
6 hobbies: string[]
7}
8
9function merge<T extends BasicInfo, U extends AdditionalInfo>(a: T, b: U) {
10 return {
11 ...a,
12 ...b
13 }
14}
15
16const person = merge({ name: 'Sam' }, { hobbies: ['sneaking'] })
17person.name // You can be sure there is the name property.

Wyobraźmy sobie jeszcze inny scenariusz - chcemy mieć dostęp do właściwości obiektu, posyłając string. W takim scenariuszu, możemy wykorzystać słowo kluczowe keyof. Pierwszy parametr w poniższym przykładzie powinien być obiektem. Drugi parametr powinien być kluczem w tym obiekcie. Operator keyof przyjmuje obiekt i zwraca unię na którą składają się jego klucze.

Słowo kluczowe keyofTS
1function extract<T extends object, U extends keyof T>(obj: T, key: U) {
2 return obj[key]
3}
4
5extract({ name: 'Sam' }, 'name')
6// TypeScript error:
7// Argument of type '"hobbies"' is not assignable to parameter of type '"name"'.
8extract({ name: 'Sam' }, 'hobbies')

Aby przeczytać więcej o typach union lub literal, sprawdź mój post o typach podstawowych w języku TypeScript.

Generyczna klasa

Podobnie do funkcji, możesz zapisywać generyczne klasy. Składnia jest w zasadzie jednakowa - wykorzystując ostre nawiasy. Taka klasa może być jednocześnie elastyczna i silnie typowana.

Generyczna klasaTS
1class Data<T extends string | number | boolean> {
2 private data: T[] = []
3
4 addItem(item: T) {
5 this.data.push(item)
6 }
7
8 removeItem(item: T) {
9 this.data.splice(this.data.indexOf(item), 1)
10 }
11}
12
13const text = new Data<string>()
14text.addItem('Sam')
15text.addItem(50) // TypeScript error
16
17const number = new Data<number>()
18number.addItem(50)

Nie ograniczyliśmy powyższej klasy do jednego typu. Na początku TypeScript nie wie, jakiego jest typu. Ale później TypeScript rozpoznaje podesłany typ - przy wywołaniu funkcji lub inicjalizacji klasy. Możesz być nawet bardziej granularny i wprowadzić nowe typy generyczne, wewnątrz metod lub właściwości klasy. Ten zapis jest przydatny, gdy nie potrzebujesz ich w całej klasie.

Typy generyczne wewnątrz klasyTS
1class Data {
2 constructor(private data: Array<string | number | boolean> = []) {}
3
4 getItems() {
5 return [...this.data]
6 }
7}
8
9const string = new Data(['Sam', 'Fisher'])
10const number = new Data([68])
11const object = new Data([{ name: 'Sam' }]) // TypeScript error.

Typy generyczne, a union types

Widząc poprzedni fragment kodu, możesz zapytać: “czym to się różni od union types?”. No tak, zapis wygląda podobnie, ale te typy działają inaczej. Myślę, że wykorzystując przykłady, będzie najłatwiej pokazać tę różnicę. Wróćmy do naszej klasy.

Typy generyczne, a union typesTS
1class Data<T extends string | number | boolean> {
2 private data: T[] = []
3
4 addItem(item: T) {
5 this.data.push(item)
6 }
7
8 removeItem(item: T) {
9 this.data.splice(this.data.indexOf(item), 1)
10 }
11}

Zapisując naszą klasę w ten sposób, wykorzystując typy generyczne, mówimy: “musisz wybrać pomiędzy typami danych, który z nich chcesz przechowywać, a następnie możesz dodawać tylko taki typ danych”. Wykorzystuj generyki jeżeli potrzebujesz elastyczności, ale jednocześnie chcesz się trzymać konkretnego typu. Wykorzystując union types, sprawa wygląda inaczej.

Typy generyczne, a union typesTS
1class Data {
2 private data: (string | number | boolean)[] = []
3
4 addItem(item: string | number | boolean) {
5 this.data.push(item)
6 }
7
8 removeItem(item: string | number | boolean) {
9 this.data.splice(this.data.indexOf(item), 1)
10 }
11}

Tutaj nie mówimy, że jest to tablica stringów, tablica liczb lub tablica wartości boolean. Może ona przechowywać mieszankę wymienionych przed chwilą typów. To samo tyczy się funkcji. Wykorzystując union types, możemy zaakceptować każdy z tych typów, przy każdym wywołaniu metody.

Generyczne typy użytkowe

Istnieje wiele przykładów generycznych typów użytkowych. Są one jak zbiór pomocniczych funkcji, które asystują przy częstych transformacjach typów. Te typy są dostępne globalnie i były dodawane stopniowo do kolejnych wersji TypeScripta. Wymienię kilka z nich.

Partial

W czystym JavaScripcie możesz stworzyć obiekt, a następnie dodawać do niego właściwości. TypeScript nie lubi takiego dodawania “w locie”. Ale powiedzmy, że potrzebujesz takiej funkcjonalności.

Typ użytkowy PartialTS
1interface Computer {
2 name: string
3 description: string
4}
5
6function updateComputer(computer: Computer, fieldsToUpdate: Partial<Computer>) {
7 return {
8 ...computer,
9 ...fieldsToUpdate
10 }
11}
12
13const desktop: Computer = {
14 name: 'MacBook Pro',
15 description: 'It has a dedicated GPU.'
16}
17
18const laptop = updateComputer(desktop, { description: 'It has a 16" display.' })

Partial owija typ i ustawia jego właściwości jako opcjonalne. W naszym przykładzie musimy ustalić name i description podczas tworzenia obiektu. Ale, wykorzystując typ partial, nie musimy podawać obu wartości, gdy chcemy zaktualizować jeden z obiektów. Pola są opcjonalne i możemy je aktualizować niezależnie.

Required

Jest to przeciwieństwo typu partial. Konstruuje typ zawierający wszystkie właściwości podanego typu, ustawiając je jako wymagane. Wiedząc to, możemy przepisać nasz poprzedni fragment kodu.

Typ użytkowy RequiredTS
1interface Computer {
2 name?: string
3 description?: string
4}
5
6function updateComputer(
7 computer: Required<Computer>,
8 fieldsToUpdate: Computer
9) {
10 return {
11 ...computer,
12 ...fieldsToUpdate
13 }
14}
15
16const desktop: Required<Computer> = {
17 name: 'MacBook Pro',
18 description: 'It has a dedicated GPU.'
19}
20
21const laptop = updateComputer(desktop, { description: 'It has a 16" display.' })

Readonly

Działa podobnie do dwóch pozostałych. Różnica polega na tym, że zmienia wszystkie właściwości na takie tylko do odczytu. Nie możesz im póżniej ponownie przypisać wartości.

Typ użytkowy ReadonlyTS
1interface Computer {
2 id: string
3}
4
5const laptop: Readonly<Computer> = {
6 id: 'ga6sd798'
7}
8
9// TypeScript error:
10// Cannot assign to 'id' because it is a read-only property.
11laptop.id = 'ags98kd'

Nie miałoby to sensu, gdybym zaczął wymieniać wszystkie te typy i zaczął kopiować dokumentację, więc na tym poprzestanę. Sprawdzając trudność tego tekstu, dostałem informację, że powinien być on zrozumiały dla czytelnika z 8 klasy szkoły podstawowej (13 - 14 lat) i dla większości dorosłych. Jest to więcej niż osiem lat, ale mam nadzieję, że ten post tak czy siak był jasny. Generyczne typy istnieją, aby ułatwić ci życie oraz zapewnić kombinację elastyczności i bezpieczeństwa związanego z typami. Mam nadzieję, że ten post nie był zbyt generyczny i że teraz potrafisz użyć typów generycznych na swoją korzyść.

Newsletter, który rozpala ciekawość💡

Subskrybuj mój newsletter, aby otrzymywać comiesięczną dawkę:

  • Nowości, przykładów, inspiracji ze świata front-end, programowania i designu
  • Teorii naukowych i sceptycyzmu
  • Moich ulubionych źródeł, idei, narzędzi i innych interesujących linków
Nie jestem nigeryjskim księciem, aby oferować ci okazje. Nie wysyłam spamu. Anuluj kiedy chcesz.

Pozostań ciekawy. Przeczytaj więcej

Połowa winyla na białym tle1 września 20227 min. czytania

Dostępne animacje w React

Czyli jak nie kręcić swoimi użytkownikami (jak winylem). Niektóre animacje mogą powodować problemy u użytkoników. Zadbamy o nich i sprawimy, że nieistotne animacje będą opcjonalne.

Czytaj wpis
Zdjęcie zmiennych CSS w edytorze Visual Studio Code14 września 20227 min. czytania

Konwertowanie tokenów projektowych na zmienne CSS z Node.js

Konwertowanie tokenów projektowych jest procesem podatnym na błędy - przekonałem się o tym na własnej skórze. Dlatego stworzyłem prosty skrypt dla środowiska Node.js, który pomoże mi z tym zadaniem.

Czytaj wpis
Pięć metalowych kół zębatych na czarnym tle23 września 202210 min. czytania

Gatsby z Netlify CMS

W tym wpisie przyjrzymy się bliżej Netlify CMS. Jest to przykład CMSa nowego typu, który jest oparty o Git. Zintegrujemy go z przykładowym projektem Gatsby.

Czytaj wpis