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:
| Sytuacja | Przykładowe brandy |
|---|---|
| Identyfikatory z różnych tabel | UserId, OrderId, ProductId |
| Zwalidowane napisy | Email, Url, NonEmptyString, Slug |
| Jednostki i waluty | Meters, Feet, Cents, Usd, Eur |
| Oczyszczony lub escapowany tekst | SafeHtml, SqlIdentifier |
| Liczby z zakresu | Percentage, 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ę.