Strona główna / Artykuły / Identyfikatory z marką w TypeScript: Jakie kodykowanie faktycznie zapobiegają błędnemu usuwaniu.

Identyfikatory z marką w TypeScript: Jakie kodykowanie faktycznie zapobiegają błędnemu usuwaniu.

Sześć sposobów wpisywania UserId i InvoiceId porównanych w jednym teście: które z nich powodują odrzucenie funkcji deleteInvoice(userId) przez tsc oraz w których przypadkach Zod zapewnia bezpieczeństwo podczas wykonywania kodu.

2472 słów

Załóżmy istnienie funkcji pomocniczej o nazwie deleteInvoice, której pierwszym parametrem powinien być identyfikator faktury, natomiast w miejscu wywołania przekazywany jest zamiast tego identyfikator użytkownika. Jeśli tsc zakończy się kodem wyjścia 0, to wszystkie typy identyfikatorów, które zadeklarowaliśmy, są jedynie dokumentacją, a dokumentacja nigdy nie powstrzymała destruktywnej zapytania. Ten przewodnik przeprowadza proste doświadczenie na sześciu popularnych sposobach kodowania UserId i InvoiceId, pokazuje, które z nich sprawiają, że kompilator odrzuca błędne wywołanie, i kończy się praktyczną zasadą dotyczącą tego, gdzie należy umieszczać etykiety, gdzie weryfikować na bieżąco oraz jak znaleźć operacje przekształcania typów, które po cichu unieważniają wszystko to.

Dlaczego dwa aliasy typu string są tego samego typu

System typów TypeScript jest strukturalny. Dwa typy obiektów o tej samej strukturze są wzajemnie zamienialne, a dwa aliasy typu string wcale nie są dwoma odrębnymi typami – to ten sam string pod różnymi nazwami. Narzędzie sprawdzające nie ma żadnych kryteriów, aby je odróżnić.

Rozwiązaniem tego problemu jest użycie „brandingu”, polegającego na dodaniu fikcyjnej właściwości do typu. Ta właściwość nigdy nie istnieje w czasie wykonywania kodu; istnieje jedynie po to, aby narzędzie sprawdzające traktowało UserId i InvoiceId jako różne struktury. Brandingi typu intersekcji, unique symbol, prefiksy literów szablonowych oraz metoda .brand() z biblioteki Zod to wszystko warianty tego samego sposobu rozwiązania.

Dwa operatory, które często mylone są z brandingami, zostały włączone do porównań właśnie z powodu tej pomyłki: satisfies i as const. Żaden z nich nie tworzy odrębnego typu.

Pamiętaj o jednej kluczowej kwestii: po usunięciu elementów typu TypeScript każda z poniższych form kodowania to zwykła ciąg znaków. Node nie ma pojęcia o istnieniu żadnych z tych typów. Jedyną ochroną jest to, co narzędzie sprawdzające wdraża podczas kompilacji, plus wszelkie dodatkowe walidacje dodane wyraźnie w czasie wykonywania.

Test: jedna nieprawidłowa wywołanie

Każda forma kodowania musi radzić sobie z tym samym miejscem wywołania. Funkcja oczekuje wartości typu InvoiceId, natomiast z innej strony pochodzi wartość typu UserId – pytanie brzmi, czy kompilator będzie miał zastrzeżenia.

declare function deleteInvoice(id: InvoiceId): Promise<void>;
const userId = getUserId(); // UserId
await deleteInvoice(userId);

1. Zwykłe aliasy typów

Tak zaczyna się większość baz kodu.

type UserId = string;
type InvoiceId = string;

Kod kompiluje się, a błędna wartość znika. Ponieważ oba nazwy oznaczają string, narzędzie sprawdzające nie ma podstaw do zastrzeżeń. To jest podstawowy problem, który pozostałe rozwiązania próbują naprawić.

2. Typy literackie z użyciem as const

Tutaj identyfikator użytkownika jest literalem, a identyfikator faktury to typ literalu szablonowego z obowiązkowym prefiksem.

const userId = "usr_123" as const;
type InvoiceId = `inv_${string}`;

To działa tylko w ograniczonym przypadku. Jeśli userId rzeczywiście ma typ literalu "usr_123", nie można go przypisać do `inv_${string}`, co skutkuje odrzuceniem wywołania. Jednak rzeczywiste identyfikatory pochodzą z funkcji, żądań i baz danych, a getter taki jak getUserId() zazwyczaj zwraca typ string. Gdy to się dzieje, wracamy do opcji 1. Dodanie as const do czegoś, co jest już typu string, nie zmienia tego na nic przydatnego. Należy również zauważyć, że to właśnie typ literalu szablonowego pełni tu kluczową rolę, a nie as const.

3. spełnia warunek string

To wzorzec pojawia się podczas przeglądania kodu jako środek bezpieczeństwa.

const userId = getUserId() satisfies string;

Kompiluje się. Funkcja satisfies sprawdza, czy wyrażenie odpowiada określonemu typowi, zachowując przy tym własny typ wywnioskowany z wyrażenia; nigdy nie wprowadza nowego nominalnego typu. Jest to przydatny operator, bardziej podobny do narzędzia korekty pisowni niż do mechanizmu typowania, i nie zapewnia ochrony przed przekazaniem niewłaściwego identyfikatora.

4. Typ intersekcji

Intersekcja typu string z obiektem posiadającym pole odczytu tylko __brand nadaje każdemu identyfikatorowi unikalną strukturę.

type UserId = string & { readonly __brand: "UserId" };
type InvoiceId = string & { readonly __brand: "InvoiceId" };

Teraz tsc odrzuca instrukcję deleteInvoice(userId). To wersja, która działa bez żadnych bibliotek. Kosztem jest to, że surowe łańcuchy tekstowe już nie są dopuszczalne, więc każdy typ typowany musi mieć konstruktor, który przekształca zweryfikowany łańcuch w odpowiedni typ:

function asUserId(raw: string): UserId {
  if (!raw.startsWith("usr_")) throw new Error("not a user id");
  return raw as UserId;
}

To as wewnątrz konstruktora to nieunikniona luka. Jeśli konstruktor jest publiczny i nie przeprowadza żadnych sprawdzeń, staje się narzędziem do nadawania fałszywych etykiet. Walidacja prefiksu to rozsądna kontrola, gdy identyfikatory rzeczywiście zawierają prefiksy. Jeśli twoje identyfikatory to UUID-y bez prefiksu, nie zmieniaj formatu przechowywania tylko po to, by umożliwić tę kontrolę; waliduj to, co faktycznie jest prawdą dotyczącym wartości, takie jak format UUID lub fakt, że została ona właśnie odczytana z tabeli faktur.

5. unikalny symbol marki

Zamiast właściwości nazwanej łańcuchem, klucz marki to unikalny symbol deklarowany raz.

declare const invoiceBrand: unique symbol;
type InvoiceId = string & { [invoiceBrand]: true };

Kompilator odrzuca błędne wywołanie dokładnie tak jak w opcji 4. Ponieważ symbol jest deklarowany w jednym module, innemu plikowi trudniej jest sfałszować markę poprzez stworzenie typu obiektu o tym samym kluczu. Kompromis dotyczy czytelności: ten wzorzec wymaga więcej wyjaśnień w pull request niż wersja z __brand.

6. Zod brand

Zod może przypisać markę do typu, który wywnioskował, i w odróżnieniu od wszystkich powyższych opcji może również sprawdzić wartość w czasie wykonywania.

const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
type InvoiceId = z.infer<typeof InvoiceId>;

Zawołanie z identyfikatorem UserId marki Zod nie przechodzi sprawdzenia typu, a analiza usr_123 według schematu faktury kończy się błędem w czasie wykonywania, ponieważ reguła startsWith("inv_") ją odrzuca. To właśnie takie sprawdzenie w czasie wykonywania nie może zapewnić czysto statyczne kodowanie: wartość pochodząca z niepewnego źródła, nawet jeśli jest już błędnie oznaczona, zostaje wykryta podczas przechodzenia przez funkcję parse. Ręczne przekształcenie w typ as InvoiceId w innym miejscu nadal całkowicie omija Zod, więc ochrona obowiązuje tylko w przypadku wartości, które rzeczywiście przechodzą przez schemat.

Podsumowanie

  • Zwykłe aliasy: kompilują się, ale błędne operacje usuwania przechodzą bez korekty.
  • as const: kompiluje się, gdy tylko wartość źródłowa ma typ string.
  • satisfies string: kompiluje się.
  • Marka typu intersekcji: odrzucana przez tsc.
  • unique symbol brand: odrzucony przez tsc.
  • Zod brand: odrzucony przez tsc, a surowy identyfikator użytkownika jest również odrzucany w czasie wykonywania przez parse.
  • Innymi słowy, trzy opcje, które ludzie często uważają za sposób wpisywania identyfikatorów, w ogóle nie pomagają przy tym błędzie, natomiast trzy prawdziwe rozwiązania zapobiegają mu, gdy są używane poprawnie.

    Odtworzenie porównania we własnym projekcie

    Umieść sześć formatowań w pliku typu src/ids.ts, dodaj dla każdego z nich nieprawidłową funkcję deleteInvoice(userId) i uruchom kompilator bez wypisywania żadnych wyników:

    pnpm exec tsc --noEmit
    

    Następnie weź identyfikator użytkownika i przepuść go przez schemat Zod, tak jak wartość skradziona lub błędnie podana mogłaby dotrzeć z żądania:

    InvoiceId.parse(String(userId));
    

    Jeśli to przetwarzanie się uda, oznacza to, że dany brand jest jedynie etykietą bez żadnego warunku sprawdzającego.

    Nie testuj brandingu, pisząc id as InvoiceId tuż obok definicji. Przekształcenie typu zawsze się kompiluje, więc taki test nic nie dowodzi.

    Znajdowanie przekształceń typu, które już Ci szkodzą

    Branding jest tak silny, jak liczba miejsc, w których są one omijane. Szukaj bezpośrednich przekształceń typu:

    rg "as InvoiceId|as UserId" src app
    

    Długa lista oznacza, że branding jest głównie dekoracyjny. Popraw konstruktory oraz obsługę granic przed wprowadzaniem kolejnych typów z brandingiem.

    Co kontroler może, a czego nie może zagwarantować

    Branding jest iluzoryczny: generowany JavaScript to nadal string. Kontroler chroni Cię w miejscu wywołania tylko wtedy, gdy wartość nigdy nie przeszła przez as InvoiceId i nigdy nie trafiła do funkcji, która przyjmuje zwykły string i zwraca branding bez żadnej weryfikacji.

    satisfies pozostaje najczęściej występującym błędnym typem w recenzjach. Jest dobrym narzędziem do wykonywania swojej funkcji, ale nie służy do typowania nominalnego.

    Typy literówki szablonowej, takie jak `inv_${string}`, zachowują się w pewnym stopniu jak typy nominalne i mają tę zaletę, że prefiks jest dokumentowany w samym typie. Ulegają awarii, gdy identyfikatory to UUID-y bez prefiksu. Należy dostosować typ do danych, a nie bazę danych do typu.

    Rozwiązanie, które dobrze sprawdza się w praktyce, to marki Zod na granicy publicznej oraz marki wewnątrz aplikacji. Przetłumacz dane raz, gdy wpływają one do aplikacji – na przykład w obsłudze żądań – i niech typ z marką zapewni ochronę dalej wewnątrz aplikacji. Ponowne przetwarzanie przy każdym przejściu między trasą taką jak /invoices a procesem w tle tylko zwiększa koszty. Aby dowiedzieć się, jak skoncentrować tę granicę, zapoznaj się z ochroną granicy Express za pomocą jednego middleware’a Zod.

    Jakie są koszty brandingu

    • Konstruktory. Każda marka wewnątrz aplikacji wymaga jednego konstruktora. Dwa typy ID oznaczają dwie małe funkcje, a nie dwadzieścia.
    • Fałszywa pewność siebie. Jedno wyrażenie as InvoiceId umieszczone tuż po JSON.parse w tajemnicy znosi ochronę dla wszystkich elementów poniżej.
  • Analiza w czasie wykonywania. Zod pełni podwójną funkcję – weryfikuje dane i nadaje im etykietę, a opłata naliczana jest za każdą analizę przy wprowadzaniu danych. Jest to opłacalne na granicy publicznej, ale zazwyczaj zbyt obciążające przy przekazywaniu danych wewnątrz systemu, gdy dane zostały już sprawdzone. Na trasach o wysokim obciążeniu należy zmierzyć tę kosztowność.
  • Korzyści. Jeden nieprawidłowy wywołanie, które przeszło kompilację, może usunąć wiersz, którego nie da się przywrócić. W porównaniu z tym nieudana kompilacja za pomocą tsc nie kosztuje nic, i to właśnie stanowi całą wartość tego „widmowego” pola.
  • Rzeczywisty błąd i skuteczne rozwiązanie

    Rozważmy narzędzie wewnętrznego wsparcia, w którym zarówno UserId, jak i InvoiceId zostały zadeklarowane jako type X = string. Ekran przedstawiający użytkownika zawiera jego ID w adresie URL, a operacja usuwania na tym ekranie odczytuje to ID z URL i przekazuje je do deleteInvoice. Kod kompiluje się, a znika rekord użytkownika zamiast faktury.

    Kuszącym rozwiązaniem jest przemianowanie parametrów, aby lepiej oddać intencję. To nie pomaga – kolejne niedbale napisane wywołanie również kompiluje się bez problemów.

    Prawidłowym rozwiązaniem jest użycie typu intersekcji oraz konstruktora walidującego w ramach aplikacji:

    type InvoiceId = string & { readonly __brand: "InvoiceId" };
    function asInvoiceId(raw: string): InvoiceId {
      if (!raw.startsWith("inv_")) throw new Error("not an invoice id");
      return raw as InvoiceId;
    }
    

    A na poziomie protokołu HTTP – schemat Zod, który zarówno waliduje dane, jak i nadaje im identyfikator:

    const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
    

    Po tej zmianie przycisk usuwania w wierszu faktury otrzymuje swoje id za pomocą asInvoiceId z pola, które faktycznie przechowuje id faktury. Ekran użytkownika może zachować id użytkownika w swojej URL, ponieważ ten ekran dotyczy właśnie użytkownika. Typ powinien był wykryć oryginalny helper; podobnie jak jaśniejsze oznaczenia. Zespołowi brakowało jednego i drugiego.

    Ostatnia weryfikacja: wyszukiwanie as InvoiceId w źródle powinno zwrócić prawie nic, a każdy wynik powinien mieć uzasadnienie.

    Czynienie weryfikacji powtarzalną

    Krótka, powtarzalna procedura weryfikacji zapobiega temu, by te wyniki stały się elementem folkloru. Zacznij od zapisania wersji narzędzi, ponieważ ich zachowanie może ulegać zmianom pomiędzy głównymi wersjami. Podstawowym ustawieniem do tej porównania była mała aplikacja do faktur z czterema trasami na Node 24, TypeScript 7 i Next.js 16.3; sprawdź wersje w swoim projekcie przed porównywaniem wyników.

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    Jeśli główna wersja różni się od tej, której oczekujesz, zatrzymaj się przed ufaniem późniejszym wynikom. Następnie uruchom aplikację i przetestuj odpowiednie trasy:

    pnpm exec next dev
    

    Odwiedź /, /invoices, /invoices/1, /settings oraz ponownie /invoices z włączoną funkcją przechowywania logów w DevTools, aby móc zobaczyć, jaki identyfikator znajduje się w URL każdej strony.

    Na koniec uruchom sprawdzacz typów w formie przyjaznej do skryptów i sprawdź stan zakończenia:

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    Kod wyjścia równy zero nie jest dowodem na poprawność produktu. Oznacza on jedynie, że warstwa kompilacji nie znalazła żadnych problemów, a zachowanie w czasie wykonywania nadal wymaga sprawdzenia. Pomocne jest również zapisywanie na osobnej linii informacji o każdej nieudanej próbie („próbowano X, nadal wystąpił Y”) obok wersji i wyników, aby kolejna osoba nie powtórzyła tych błędów.

    Powszechne sposoby, w jakie marki ponoszą porażkę

    • as InvoiceId bezpośrednio po JSON.parse: wtedy marka staje się jedynie elementem dekoracyjnym.
    • satisfies string przyjmowane podczas sprawdzania, jakby była to marka: sprawdza ono jedynie zgodność.
    • Użycie przedrostka literalu szablonowego w kolumnie UUID, po czym ktoś dodaje ten przedrostek do przechowywanych danych, aby typ pasował. Należy to cofnąć – zamiast modyfikować bazę danych, należy oznaczyć przetworzoną wartość jako markę.
  • Konstruktor taki jak asInvoiceId eksportowany z pliku barrel, dzięki czemu jest łatwo dostępny dla każdego modułu chcącego pominąć weryfikację.
  • Listwa kontrolna przed wywołaniem typu id

    • Nie jest on deklarowany jako type FooId = string.
    • satisfies string nie jest jego jedynym warunkiem.
    • Konstruktor lub mechanizm parsowania Zod chroni go na granicy.
    • deleteInvoice(userId) powoduje błąd w tsc.
    • Wyniki wyszukiwania as InvoiceId tworzą krótką listę, którą można uzasadnić.

    Rola kontekstu też jest ważna. Nie ma potrzeby oznaczać każdej ciągu znaków w repozytorium. Oznacz id, które mogą zniszczyć lub ujawnić dane: ścieżki dotyczące usuwania, zwrotów pieniędzy i podszywania się to dobre kandydatury. Jeśli ostatecznie masz pięćdziesiąt takich oznaczeń, to raczej dekorujesz niż chronisz.

    Kompaktowy zestaw poleceń obejmuje bieżące sprawdzania, w tym wyszukiwanie pozostałych aliasów identyfikatorów w postaci zwykłych ciągów znaków:

    pnpm exec tsc --noEmit
    rg "as InvoiceId" src
    rg "type \w+Id = string" src
    

    Nieprawidłowa funkcja deleteInvoice(userId) powinna znajdować się w pliku testowym, który ma zawieść sprawdzanie typów (na przykład dzięki komentarzowi @ts-expect-error nad nią), a nigdy w kodzie produkcyjnym takim jak lib/delete.ts.

    Przetestuj to w swoim kodzie

    Napisz nielegalną funkcję deleteInvoice(userId) obok helpera do usuwania, którego faktycznie używasz, w miejscu, gdzie sprawdza to kompilator. Jeśli tsc pozostanie cichy, twoje identyfikatory to tylko komentarze. Przekonwertuj InvoiceId na typ typu przecięcia i upewnij się, że wywołanie stanie się czerwone. Dodaj konstruktor walidujący lub typ Zod na granicy HTTP i sprawdź, czy surowy tekst "usr_123" powoduje błąd. Następnie poszukaj wyrażenia as InvoiceId i albo uzasadnij każdy taki przypadek w pull request, albo go usuń.

    Główne wnioski

    • Aliasy, as const oraz satisfies nie tworzą odrębnych typów, więc nie mogą zapobiec użyciu błędnego identyfikatora.
    • Typy przecięcia oraz unique symbol sprawiają, że kompilator odrzuca błędne wywołania; typy Zod dodają sprawdzenie w czasie wykonywania dla wartości, które przechodzą przez funkcję parse.
  • Każda marka ma „drzwi ewakuacyjne” w as. Umieszczaj konwersje w małych konstruktorach walidujących i sprawdzaj resztę.
  • Analizuj i przypisuj markę raz na granicy, przenoś statyczną markę do wewnątrz i zachowaj przypisywanie marek dla identyfikatorów, których niewłaściwe użycie powoduje nieodwracalne szkody.