W pierwszym artykule serii „trochę więcej o typach” poruszyłem kilka zagadnień związanych z typami, takich jak union types
czy intersection types
. Dzisiaj przytoczę garść kolejnych udogodnień przeznaczonych do pracy z nimi.
Polimorficzny typ this
Wyobraźmy sobie prostą implementację wzorca projektowego Builder
dla klasy typu Product
:
class ProductBuilder {
private name: string;
private price: number;
setPrice(price: number): ProductBuilder {
this.price = price;
return this;
}
setName(name: string): ProductBuilder {
this.name = name;
return this;
}
build() {
return new Product(name, price);
}
}
Dzięki temu, że metody służące do konstruowania instancji klasy Product
zwracają referencję na obiekt (this
) można swobodnie łączyć kolejne wywołania funkcji.
Problem pojawia się w sytuacji, kiedy postanowimy rozszerzyć klasę ProductBuilder
, przykładowo – wywodząc z niej klasę pochodną ExtendedProductBuilder
(jest to niewątpliwie typowo dydaktyczny przykład):
class ExtendedProductBuilder extends ProductBuilder {
private description: string;
setDescription(description: string): ExtendedProductBuilder {
this.description = description;
return this;
}
build() {
return new Product(name, price, description);
}
}
Źródłem problemu są w tym przypadku metody setPrice()
oraz setName()
– w końcu zwracają one typ ProductBuilder
, a nie ExtendedProductBuilder
.
Rozwiązaniem jest wykorzystanie typu this
:
setName(name: string): this {
this.name = name;
return this;
}
Teraz typ będzie zgodny z typem kontekstu wywołania metody.
Operator keyof
Operator keyof
pozwala na wyciągnięcie wszystkich możliwych składowych typu złożonego w postaci unii. Najlepiej obrazuje to poniższy przykład:
interface User {
login: string;
password: string;
}
let userProperties: keyof User; // "login" | "password"
userProperties = "login";
Jeżeli chcielibyśmy do zmiennej userProperties
przypisać inną wartość niż „login” lub „password”, transpiler zareaguje następującym komunikatem błędu:
Type ‚”…”‚ is not assignable to type ‚”login” | „password”‚.
Operator keyof
jest przydatny między innymi w kombinacji z mapowaniem typów.
Mapped types
Mapowanie typów to użyteczny mechanizm, pozwalający nam utworzyć dynamiczny typ na bazie istniejącego. Najlepiej obrazuje to poniższy przykład wprost z dokumentacji:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
W tym przypadku poniższy zapis:
let user: Readonly<User>;
Spowoduje, że próba przypisania wartości do jakiegokolwiek pola obiektu typu User
zakończy się wystąpieniem komunikatu o błędzie:
Cannot assign to ‚login’ because it is a constant or a read-only property.
Warto zwrócić uwagę na konstrukcję in keyof
, która powinna kojarzyć się z pętlą for..in
iterującą po kluczach kolekcji. W tym przypadku in keyof
oznacza iterację po wszystkich wartościach unii. Powyższy zapis typu Readonly
przy konkretyzacji dla typu User
wygeneruję typ posiadający następujące pola:
readonly login: string;
readonly password: string;
Kolejną istotną kwestią jest konstrukcja T[P]
. Jeżeli przy każdej kolejnej iteracji P
jest następną wartością unii, np. „login”, to bezpośrednie wywołanie T['user']
powinno zwrócić adekwatny typ dla tego pola.
Czy tak to działa? Tak. Poniższy zapis spowoduje, że zmienna login
otrzyma typ string
:
let login: User['login']; // string
To wszystko. Jak widać, mechanika tego rozwiązania nie jest tak skomplikowana, jak na pierwszy rzut oka mogłoby się wydawać.
Podsumowanie
W dzisiejszym artykule przedstawiłem kolejne, interesujące zagadnienia związane z typami w TypeScript. Była to druga i zarazem ostatnia część serii „trochę więcej o typach”. W następnym artykule pojawi się co nieco o przestrzeniach nazw.