Type Narrowing – Zawężanie typów w TypeScript

  • typescript

Type Narrowing czyli zawężanie typów polega na… zawężeniu typów ;). Czyli usunięciu ze zbioru (unii) tych typów, które nie pasują do określonych kryteriów.

W praktyce, Type Narrowing pozwala nam bezpiecznie operować na zmiennych, które mogą mieć wiele różnych typów. Dzięki temu TypeScript może sprawdzić w czasie kompilacji, czy używamy właściwych metod i właściwości dla danego typu, co zabezpiecza nasz kod na produkcji runtime.

Type Guards

To pojęcie odnosi się do wszystkich technik zawężania typów. Gdy usłyszysz, że trzeba zastosować “type guard”, “guarda” czy jakąkolwiek inną odmianę tego pojęcia, to chodzi właśnie o zawężenie typu na jakikolwiek zależny od sytuacji sposób.

Typowanie strukturalne

W rozległym świecie języków programowania istnieją dwa główne systemy typów: nominalny oraz strukturalny. Ponieważ TypeScript bazuje na systemie strukturalnego typowania, skupię się wyłącznie na tej definicji.

W systemie typowania strukturalnego istotna jest wyłącznie zawartość – struktura – danego typu. Dwie identyczne struktury są traktowane jako ten sam typ, nawet jeżeli zostały zdefiniowane oddzielnie i nie są powiązane nazwą.

W kontekście TypeScript możesz spotkać się z potocznym stwierdzeniem duck typing, które w humorystyczny sposób opisuje ten system – jeżeli coś wygląda i kwacze jak kaczka (zgadza się struktura) to prawdopodobnie jest to kaczka.

Natomiast w kontekście JavaScript duck typing odnosi się do zachowania – jeżeli coś zachowuje się jak kaczka (ma odpowiednie właściwości i metody) to prawdopodobnie jest to kaczka.

Zanim zaczniemy

Wszystkie przykłady będą oparte na poniższym zestawie typów. Dla dodatkowego uproszczenia, wszystkie przykłady są wyłącznie deklaracjami.

class Car {
  kind = "car";
  wroom(): void {}
}

type Vehicle = Car;

class Cat {
  kind = "cat";
  meow(): void {}
}

class Dog {
  kind = "dog";
  bark(): void {}
}

type Animal = Cat | Dog;

Control Flow Analysis

Jest to najbardziej powszechna metoda zawężania typów w TypeScript, ponieważ wykorzystuje podstawową składnię JavaScript, którą już doskonale znasz: instrukcje warunkowe if oraz switch.

Truthiness Narrowing

Zawężenie typów do wyłącznie wartości truthy lub falsy.

declare const something: string | Animal | null | undefined;

if (something) {
  something // string | Animal
} else {
  something // null | undefined
}

Equality Narrowing

Zawężanie typów na podstawie porównania wartości.

declare const numberOrNull: number | null;

if (numberOrNull !== null) {
  numberOrNull // number
} else {
  numberOrNull // null
}

Zawężanie po instancji obiektu (instanceof)

Operator instanceof sprawdza, czy dana instancja obiektu dziedziczy z docelowego konstruktora. Zazwyczaj kojarzymy to w kontekście klasy, jednak będąc bardziej specyficznym to w JavaScript nie ma klas, więc odnosimy się tutaj do dziedziczenia prototypowego.

declare const animal: Animal;

if (animal instanceof Cat) {
  animal.meow();
}

Zawężanie po właściwości w obiekcie (in)

Za pomocą operatora in możesz sprawdzić, czy konkretna właściwość znajduje się w obiekcie.

Przypominając sobie o zasadzie typowania strukturalnego, możemy stwierdzić, że jeżeli obiekt posiada właściwość meow to prawdopodobnie jest to Cat.

declare const animal: Animal;

if ("meow" in animal) {
  animal.meow();
}

Discriminated Unions

Zawężanie po właściwości rozróżniającej (discriminant property). Opiera się to na tej samej zasadzie co Zawężanie po właściwości w obiekcie (in) ze względu na wspomniany wcześniej system typowania strukturalnego.

declare const animal: Animal;

if (animal.kind === "cat") {
  animal.meow();
}

Type Predicate

Predykat to jest funkcja, która coś sprawdza i zwraca boolean. Najbardziej znanym predykatem jest funkcja przekazywana do Array.prototype.filter, która decyduje, które elementy tablicy mają pozostać.

W kontekście TypeScript, Type Predicate dodatkowo zawęża typ.

Ten typ funkcji charakteryzuje składnia is w deklaracji zwracanego typu.

declare function isCat(animal: Animal): animal is Cat;
declare const animal: Animal;

if (isCat(animal)) {
  animal.meow();
}

Assertion Function

Asercje nic nie zwracają, lecz rzucają wyjątek w środowisku wykonywalnym (runtime). Rzucony wyjątek przerywa dalsze wykonywanie kodu, więc TypeScript z pewnością może zawęzić typ za wykonaną asercją.

Funkcję tego typu musimy opisać składnią asserts w deklaracji zwracanego typu.

declare function assertCat(animal: Animal): asserts animal is Cat;

declare const animal: Animal;
assertCat(animal);
animal.meow();
animal.bark(); // TS Error: Property 'bark' does not exist on type 'Cat'.

Type Assertion (rzutowanie typu)

Rzutowanie nie jest dobrą praktyką. Jednak praktyka i teoria czasami nie chodzą ze sobą w parze i czasami musimy pokazać TypeScriptowi, kto wie lepiej. Pamiętaj chociaż o testach integracyjnych, gdy już musisz coś takiego wykonać.

declare const animal: Animal;
const kitty = animal as Cat;

Nie można jednak robić asercji typu na typach, które nie są ze sobą powiązane.

declare const car: Car;
const surelyNotACat = car as Cat;
// Conversion of type 'Car' to type 'Cat' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
// Property 'meow' is missing in type 'Car' but required in type 'Cat'.

Jak widzisz, nadal możesz przekonać TypeScript, używając podwójnej asercji przez unknown:

declare const car: Car;
const surelyNotACat = car as unknown as Cat;
surelyNotACat.meow(); // Runtime error: surelyNotACat.meow is not a function

Exhaustiveness Checking

Ta technika polega na wyczerpaniu wszystkich możliwych przypadków w instrukcji warunkowej.

declare const animal: Animal;

switch (animal.kind) {
  case "cat":
    animal.meow();
    break;
  case "dog":
    animal.bark();
    break;
  default:
    const _exhaustiveCheck: never = animal;
    break;
}

W przypadku gdybyśmy dodali nowy typ do unii Animal, TypeScript wyrzuci błąd kompilacji w miejscu przypisania do _exhaustiveCheck, ponieważ animal nie będzie już typu never.

class Bird {
  kind = "bird";
  chirp(): void {}
}

type Animal = Cat | Dog | Bird;

switch (animal.kind) {
  case "cat":
    animal.meow();
    break;
  case "dog":
    animal.bark();
    break;
  default:
    const _exhaustiveCheck: never = animal; // TS Error: Type 'Bird' is not assignable to type 'never'.
    break;
}

Bibliografia

Zobacz także