Menu

Branded types w TypeScript: typowanie nominalne z przykładami

Branded type to typ prosty z niewidocznym znacznikiem, na przykład string & { readonly __brand: "UserId" }, dzięki czemu UserId nie da się przekazać tam, gdzie oczekiwany jest OrderId. Zobacz, jak działają brandy, funkcje tworzące z walidacją, generyczny pomocnik Brand, brandy z unique symbol i oznaczone liczby.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

TypeScript porównuje typy według kształtu, więc dwa aliasy string są wymienne. Branded type dodaje znacznik, który istnieje tylko w systemie typów, string & { readonly __brand: "UserId" }, i dzięki temu UserId jest niezgodny ze zwykłym napisem i z każdym innym brandem:

W czasie działania userId to po prostu napis "u_42". Brand to etykieta czasu kompilacji, a jej jedynym zadaniem jest niedopuszczenie do pomylenia identyfikatorów, jednostek i zwalidowanych napisów.

Problem: aliasy to tylko nazwy

Alias typu nie tworzy nowego typu. Nadaje istniejącemu typowi drugą nazwę, a kompilator traktuje obie nazwy jako to samo:

To typowanie strukturalne: TypeScript sprawdza, czy kształt pasuje, a string pasuje do string. Obiekty zwykle mają różne kształty; identyfikatory, adresy e-mail, waluty i jednostki nigdy. Brandy rozwiązują właśnie ten jeden przypadek.

Jak działa brand

string & { readonly __brand: "UserId" } to przecięcie: wartość musi być napisem i jednocześnie mieć właściwość __brand typu "UserId". Żaden prawdziwy napis nie ma tej właściwości, więc żadnego zwykłego napisu nie da się do niego przypisać:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

Kierunek, który ma znaczenie, nadal działa: UserId jest napisem, więc możesz go przekazać do wszystkiego, co przyjmuje napis, wywołać na nim .startsWith() albo wstawić go do szablonu. Brand blokuje tylko drogę do środka.

Funkcje tworzące z walidacją

Asercja as UserId to jedyna droga do środka, a asercja niczego nie sprawdza. Umieść ją w jednej funkcji, która waliduje wejście, a każda oznaczona wartość w programie będzie wtedy na pewno po tym sprawdzeniu:

sendWelcome nigdy już nie sprawdza swojego wejścia, bo typ parametru mówi, że sprawdzenie już się odbyło. To idea „parse, don't validate”: sprawdź dane na granicy, a potem noś dowód w typie. Strażnik typu też sprawdzi się jako funkcja tworząca, gdy wolisz wartość logiczną od wyjątku: function isEmail(s: string): s is Email.

Generyczny pomocnik Brand

Ręczne pisanie przecięcia dla każdego typu szybko się powtarza. Mały typ generyczny robi to raz:

Oznaczanie liczby działa tak samo jak oznaczanie napisu. Zwróć uwagę, że amount / 100 to zwykłe number: arytmetyka na oznaczonej liczbie daje wynik bez brandu, o czym niżej.

Brandy z unique symbol

Nazwa właściwości w postaci napisu, taka jak __brand, wygląda jak prawdziwa właściwość: userId.__brand przechodzi sprawdzanie typów jako "UserId", ale w czasie działania to undefined, a dwie biblioteki mogłyby wybrać tę samą nazwę. Klucz unique symbol pozwala uniknąć obu problemów:

declare const brand: unique symbol deklaruje symbol, który istnieje tylko dla sprawdzania typów; słowo kluczowe declare oznacza, że nie jest dla niego emitowany żaden JavaScript. Ponieważ symbol nie jest eksportowany ze swojego modułu, kod w innych plikach nie może nawet nazwać właściwości brandu, więc poza tym modułem jedyne sposoby na zdobycie Meters to eksportowane przez ciebie funkcje albo asercja as Meters.

Brandy nic nie kosztują w czasie działania

Skompilowany wynik nie zawiera śladu brandu. Oto linie emitowane dla przykładu z Meters, pod nagłówkiem modułu dodawanym przez kompilator: declare, oba aliasy typów i każde as zniknęły, a stłumione wywołanie w ostatniej linii nadal się wykonuje.

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

Oznaczona wartość to zwykły typ prosty: typeof daje "string" albo "number", JSON.stringify zapisuje ją jak zwykle, a porównania działają jak wcześniej. Druga strona medalu jest taka, że w czasie działania nic nie jest sprawdzane, chyba że sprawdza to twoja funkcja tworząca. Dane sparsowane z JSON, bazy danych albo adresu URL przychodzą jako string i stają się UserId dopiero po przejściu przez tę funkcję.

Arytmetyka i metody gubią brand

Operacje na oznaczonej wartości zwracają typ bazowy, bo brand nie jest częścią tego, co produkuje + albo .slice():

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

Zwykle właśnie o to chodzi: dodanie dwóch kwot w centach daje centy, ale mnożenie centów przez centy już nie, a tylko ty wiesz, które operacje zachowują znaczenie. Napisz małe funkcje pomocnicze, takie jak addCents(a: Cents, b: Cents): Cents, dla operacji, których potrzebuje twój kod.

Kiedy używać branded types

Używaj brandów tam, gdzie pomylenie dwóch wartości tego samego typu prostego to realne ryzyko, a kompilator w inny sposób nie pomoże:

SytuacjaPrzykładowe brandy
Identyfikatory z różnych tabelUserId, OrderId, ProductId
Zwalidowane napisyEmail, Url, NonEmptyString, Slug
Jednostki i walutyMeters, Feet, Cents, Usd, Eur
Oczyszczony lub escapowany tekstSafeHtml, SqlIdentifier
Liczby z zakresuPercentage, PositiveInt

Pomiń je dla wartości, których nikt nie myli, i dla typów obiektowych, które już różnią się kształtem. Biblioteki walidujące potrafią tworzyć branded types ze schematu: w Zod z.string().brand<"UserId">() daje schemat, którego parse zwraca oznaczony UserId, co oszczędza ręcznego pisania funkcji tworzących.

Najczęściej zadawane pytania

Czym są branded types w TypeScript?

To wzorzec, który sprawia, że dwa typy o tej samej reprezentacji w czasie działania stają się niezgodne. Łączysz typ bazowy ze znacznikiem, którego nie ma żadna zwykła wartość: type UserId = string & { readonly __brand: "UserId" }. Zwykły napis albo OrderId z innym znacznikiem zostaje wtedy odrzucony tam, gdzie oczekiwany jest UserId.

Czy TypeScript ma typy nominalne?

Nie. System typów TypeScript jest strukturalny: dwa typy o tym samym kształcie są wymienne, niezależnie od nazw. Deklaracje klas ze składowymi private lub #private zachowują się nominalnie, a branded types to popularny sposób, by uzyskać ten sam efekt dla typów prostych, takich jak napisy i liczby.

Czy branded types mają koszt w czasie działania?

Nie. Brand istnieje tylko w typie. W czasie działania wartość to nadal zwykły napis albo liczba, bez dodatkowej właściwości, a skompilowany JavaScript jest taki sam jak bez brandu. Jedyny kod w czasie działania to walidacja, którą zdecydujesz się umieścić w funkcji tworzącej oznaczone wartości.

Jak utworzyć wartość typu z brandem?

Asercją typu, najlepiej w jednej małej funkcji, która najpierw sprawdza wejście: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Trzymanie as w tym jednym miejscu oznacza, że każdy Email w programie przeszedł sprawdzenie.

Czym różni się alias typu od branded type?

type UserId = string to tylko nowa nazwa: każdy napis jest akceptowany tam, gdzie oczekiwany jest UserId. type UserId = string & { readonly __brand: "UserId" } to nowy, niezgodny typ: zwykły napis musi najpierw przejść przez funkcję tworzącą albo asercję.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ