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.
Ostatnia aktualizacja 19 kwietnia 2023![Joker na szczycie rozsypanych kart](/static/69b58de53e87352d2ac75057a71d70cb/51e3c/pexels-archana-gs-6042040.jpg)
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: string2person = "Sam"34let 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"]45// 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}78merge<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}78// 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: string3}45interface AdditionalInfo {6 hobbies: string[]7}89function merge<T extends BasicInfo, U extends AdditionalInfo>(a: T, b: U) {10 return {11 ...a,12 ...b,13 }14}1516const 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}45extract({ 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[] = []34 addItem(item: T) {5 this.data.push(item)6 }78 removeItem(item: T) {9 this.data.splice(this.data.indexOf(item), 1)10 }11}1213const text = new Data<string>()14text.addItem("Sam")15text.addItem(50) // TypeScript error1617const 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> = []) {}34 getItems() {5 return [...this.data]6 }7}89const 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[] = []34 addItem(item: T) {5 this.data.push(item)6 }78 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)[] = []34 addItem(item: string | number | boolean) {5 this.data.push(item)6 }78 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: string3 description: string4}56function updateComputer(computer: Computer, fieldsToUpdate: Partial<Computer>) {7 return {8 ...computer,9 ...fieldsToUpdate,10 }11}1213const desktop: Computer = {14 name: "MacBook Pro",15 description: "It has a dedicated GPU.",16}1718const 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?: string3 description?: string4}56function updateComputer(7 computer: Required<Computer>,8 fieldsToUpdate: Computer9) {10 return {11 ...computer,12 ...fieldsToUpdate,13 }14}1516const desktop: Required<Computer> = {17 name: "MacBook Pro",18 description: "It has a dedicated GPU.",19}2021const 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: string3}45const laptop: Readonly<Computer> = {6 id: "ga6sd798",7}89// 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ść.