Typy generyczne są ściśle związane z paradygmatem programowania nazywanym programowaniem uogólnionym. Wraz z typowaniem dostarczonym nam przez TypeScript, powstała konieczność zaopatrzenia programistów w mechanizm typów generycznych, dzięki czemu modelowane zachowanie nie ogranicza się tylko do konkretnego, określonego typu danych.
Wprowadzenie
Moglibyśmy zastanawiać się, po co tak właściwie typy generyczne, jeżeli do dyspozycji mamy już any
. Nic bardziej mylnego. Rozważmy poniższy przykład:
type Resolver = () => any;
function toResolver(arg: any): Resolver {
return () => arg;
}
Utworzyliśmy funkcję, która przekształca przekazany argument na funkcję zwracającą ten sam argument. Do funkcji toResolver()
przekazujemy typ any
i adekwatnie – funkcja niższego rzędu zwraca nam również typ any
. W rezultacie otrzymaliśmy fragment kodu, który prawidłowo wykona się dla każdego przekazanego typu – o to nam w końcu chodziło.
To rozwiązanie jest fatalne, jeżeli zależy nam na podpowiadaniu składni, bezpieczeństwie typów, czy dostarczeniu dodatkowego kontekstu dla pozostałych członków zespołu, dzięki czemu mogliby w krótszym czasie zrozumieć przeznaczenie pisanego przez nas kodu.
Właśnie w tym miejscu pojawia się nasze zapotrzebowanie na typy generyczne.
Składnia
Rozpocznijmy od przekształcenia funkcji toResolver()
do swojego uogólnionego odpowiednika:
type Resolver<T> = () => T;
function toResolver<T>(arg: T): Resolver<T> {
return () => arg;
}
I to wszystko. Jeżeli TypeScript jest w stanie swobodnie wydedukować typ przekazanego argumentu, wystarczy standardowe wywołanie funkcji:
const resolver = toResolver('Plain text');
Możemy również – jeżeli uznamy to za słuszne lub będzie to konieczne – sprecyzować typ danych:
const resolver = toResolver<string>('Plain text');
Przykładowo, poniższa próba zakończy się niepowodzeniem:
const resolver = toResolver<number>('Plain text');
A transpiler wyświetli następujący komunikat błędu:
Argument of type ‚”Plain text”‚ is not assignable to parameter of type ‚number’.
Generyczne klasy i interfejsy
Wiemy już, w jaki sposób tworzyć uogólnione funkcje. W TypeScript, nic nie stoi na przeszkodzie, aby tworzyć również uogólnione klasy, jak i interfejsy. Zerknijmy na poniższy przykład:
interface Container<T> {
value: T;
}
const container: Container<number> = { value: 10 };
Stworzyliśmy interfejs, który możemy swobodnie wykorzystać dla różnych typów danych. Oczywiście, generycznych składowych różnego typu może być więcej. Zwróćmy uwagę na kolejny przykład:
interface KeyValuePair<K, T> {
key: K;
value: T;
}
const pair: KeyValuePair<string, number> = {
key: 'one',
value: 1
};
W przypadku klas – składnia prezentuje się następująco:
class Collection<T> {
private tab: T[] = [];
add(element: T): void {
this.tab.push(element);
}
insert(list: T[]): void {
this.tab.push(...list);
}
size(): number {
return this.tab.length;
}
}
let collection = new Collection<number>();
collection.insert([1, 2, 3]);
Ograniczenia
Jak można zauważyć – tworzenie typów generycznych w TypeScript to niezwykle prosty proces. Jednak czasami nie chcemy tworzyć funkcji, klas czy interfejsów, które dedykowane są dla wszystkich możliwych, dostępnych typów – czy to stworzonych przez programistę czy typów wbudowanych. Co w tym przypadku?
Aby ograniczyć zastosowanie uogólnionego fragmentu kodu do konkretnej dziedziny typów wykorzystujemy słowo kluczowe extends
, zarówno dla klas jak i interfejsów, co może być nieco mylące.
Dobrym przykładem wydają się być interfejsy ściśle powiązane ze wzorcem projektowym Command:
interface Command {
execute();
}
interface CommandHandler<T extends Command> {
handle(command: T);
}
Możemy zauważyć, że tym razem typ CommandHandler
możemy wyspecjalizować jedynie dla typów, które spełniają kontrakt Command
. Próba wykorzystania go dla m.in. typu prostego (jak przykładowo number
) zakończy się niepowodzeniem.
Podsumowanie
Dzisiejszy artykuł miał na celu zaprezentowanie większości zagadnień związanych z typami generycznymi w TypeScript. Zagadnienia te, są bardzo istotne w kontekście swobodnej pracy z typami, a wiedza w ich zakresie pozwala zredukować do minimum występowanie any
w kodzie źródłowym.