Javascript

TypeScript – trochę więcej o typach, część 2

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.