Startseite / Artikel / Markierte IDs in TypeScript: Welche Kodierungen tatsächlich ein falsches Löschen verhindern.

Markierte IDs in TypeScript: Welche Kodierungen tatsächlich ein falsches Löschen verhindern.

Sechs Möglichkeiten, UserId und InvoiceId einzugeben, im Vergleich in einem Test: Welche davon veranlassen tsc, deleteInvoice(userId) abzulehnen, und wo Zod zur Laufzeit-Sicherheit beiträgt.

2472 Wörter

Stellen Sie sich einen Hilfsfunktion namens deleteInvoice vor, deren erster Parameter eine Rechnungs-ID sein sollte, sowie einen Aufrufort, der stattdessen eine Benutzer-ID überlässt. Wenn tsc mit dem Exit-Code 0 abschließt, sind alle von Ihnen deklarierten ID-Typen lediglich Dokumentation – und Dokumentation hat noch nie eine schädliche Abfrage verhindert. In diesem Leitfaden wird ein einfaches Experiment mit sechs gängigen Methoden zur Kodierung von UserId und InvoiceId durchgeführt, gezeigt, welche davon dazu führen, dass der Compiler den fehlerhaften Aufruf ablehnt, und abschließend eine praktische Richtlinie vorgestellt, wo man Kennzeichnungen setzen, wo man zur Laufzeit validieren sollte und wie man die Casts findet, die alles heimlich rückgängig machen.

Warum zwei Zeichenketten-Alias denselben Typ haben

Das Typsystem von TypeScript ist strukturell. Zwei Objekttypen mit derselben Struktur sind austauschbar, und zwei Aliase von string sind überhaupt keine verschiedenen Typen – es handelt sich dabei um denselben string unter unterschiedlichen Namen. Der Typprüfer hat nichts, womit er sie voneinander unterscheiden könnte.

Brandings lösen dieses Problem, indem sie einer Typstruktur eine Phantom-Eigenschaft hinzufügen. Diese Eigenschaft existiert niemals zur Laufzeit; sie dient lediglich dazu, dass der Typprüfer UserId und InvoiceId als unterschiedliche Strukturen erkennt. Intersection-Brandings, unique symbol-Brandings, Präfixe für Template Literals sowie Zods .brand() sind alle Variationen dieses Tricks.

Zwei Operatoren, die häufig fälschlicherweise für Brandings gehalten werden, sind gerade aufgrund dieser Verwechslung in die Vergleichsfunktionen aufgenommen worden: satisfies und as const. Keiner von beiden erzeugt einen eigenständigen Typ.

Halten Sie sich stets eine Tatsache vor Augen: Sobald TypeScript entfernt wurde, handelt es sich bei jeder folgenden Kodierung um einen einfachen String. Node hat keine Ahnung, dass irgendwelche dieser Typen existieren. Die einzige Schutzmaßnahme besteht darin, was der Prüfer zur Kompilierzeit vorschreibt, plus jegliche zusätzlichen Laufzeitsicherungen, die Sie explizit hinzufügen.

Der Test: ein ungültiger Aufruf

Jede Kodierung muss denselben Aufrufstand bewältigen. Eine Funktion erwartet einen InvoiceId, doch ein Wert vom Typ UserId kommt von einer anderen Stelle – die Frage ist, ob der Compiler dagegen Einspruch erhebt.

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

1. Einfache Typaliasse

Dabei beginnen die meisten Codebasen.

type UserId = string;
type InvoiceId = string;

Es wird kompiliert, und die falsche Zeile verschwindet. Da beide Namen auf string abgebildet werden, hat der Prüfer keinen Grund zur Beanstandung. Dies ist das Ausgangsversagen, das die übrigen Optionen zu beheben versuchen.

2. Literaltypen mit as const

Hier ist die Benutzer-ID ein Literall und die Rechnungs-ID ist ein Template-Literal-Typ mit einem erforderlichen Präfix.

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

Das funktioniert nur in einem eng begrenzten Fall. Wenn userId tatsächlich den Literaltyp "usr_123" hat, kann es nicht der Variable `inv_${string}` zugewiesen werden, wodurch der Aufruf abgelehnt wird. Doch echte IDs stammen aus Funktionen, Anfragen und Datenbanken, und ein Getter wie getUserId() gibt in der Regel einen string-Wert zurück. Sobald das der Fall ist, kehrt man zur Option 1 zurück. Das Hinzufügen von as const zu etwas, das bereits als string typisiert ist, verengt den Typ nicht zu etwas Nützlichem. Beachten Sie außerdem, dass hier tatsächlich der Template-Literal-Typ die Funktion übernimmt, nicht as const.

3. erfüllt die Bedingung für string

Dieses Muster taucht in Code-Reviews auf und wird als Sicherheitsmaßnahme dargestellt.

const userId = getUserId() satisfies string;

Es kompiliert. satisfies überprüft, ob eine Expression einem Typ entspricht, wobei der eigene abgeleitete Typ der Expression beibehalten wird; es führt niemals einen neuen nominalen Typ ein. Es handelt sich um einen nützlichen Operator, der eher einem Rechtschreibprüfer ähnelt als einer Marke, und er bietet keinen Schutz dagegen, eine falsche ID zu übergeben.

4. Schnittpunkt-Marke

Durch das Schnittstellen von string mit einem Objekt, das ein nur zum Lesen zugängliches __brand-Feld enthält, erhält jede ID eine eindeutige Struktur.

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

Nun lehnt tsc deleteInvoice(userId) ab. Dies ist die Version, die ohne jegliche Bibliothek funktioniert. Der Nachteil dabei ist, dass rohe Strings nicht mehr verwendet werden können, weshalb jeder markierte Typ einen Konstruktor benötigt, der einen validierten String in die entsprechende Marke umwandelt:

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

Dieses as innerhalb des Konstruktors ist das unvermeidliche Problem. Wenn der Konstruktor öffentlich ist und keine Überprüfungen durchführt, wird er zu einem Werkzeug zur Erstellung falscher Etiketten. Die Überprüfung eines Präfixes ist eine sinnvolle Maßnahme, wenn Ihre IDs tatsächlich Präfixe enthalten. Wenn Ihre IDs UUIDs ohne Präfix sind, ändern Sie das Speicherformat nicht nur deshalb, um diese Überprüfung durchzuführen; überprüfen Sie stattdessen das, was tatsächlich zutrifft – beispielsweise den UUID-Format oder die Tatsache, dass der Wert gerade aus der Rechnungstabelle gelesen wurde.

5. eindeutiges Symbol für die Marke

Anstelle einer mit einem String benannten Eigenschaft ist der Markeys ein einmal deklariertes einzigartiges Symbol.

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

Der Compiler lehnt den fehlerhaften Aufruf genauso ab wie in Option 4. Da das Symbol in einem Modul deklariert ist, fällt es anderen Dateien etwas schwerer, durch das Schreiben eines Objekttyps mit derselben Schlüsselwerte eine Fälschung zu erstellen. Der Kompromiss liegt in der Lesbarkeit: Das Muster erfordert in einem Pull Request mehr Erklärungen als die __brand-Version.

6. Zod Brand

Zod kann einem von ihm abgeleiteten Typ einen Brand zuweisen und untersucht im Gegensatz zu allen oben genannten Optionen auch den Wert zur Laufzeit.

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

Der Aufruf mit einem unter der Marke Zod stehenden UserId scheitert an der Typüberprüfung, und die Verarbeitung von usr_123 gemäß dem Rechnungs-Schema fehlschlägt zur Laufzeit, da die Regel startsWith("inv_") diese Wertung ablehnt. Genau diese Laufzeitprüfung können rein statische Kodierungen nicht bieten: Ein Wert, der von einer unzuverlässigen Quelle stammt – selbst wenn er bereits falsch beschriftet ist – wird erkannt, sobald er durch die parse-Funktion läuft. Eine manuelle Umwandlung in as InvoiceId an einer anderen Stelle umgeht Zod dennoch vollständig, sodass der Schutz nur für Werte gilt, die tatsächlich durch das Schema laufen.

Bewertungskarte

  • Einfache Aliase: Kompilierung erfolgreich, falsche Löschvorgänge werden durchgelassen.
  • as const: Kompiliert, sobald der Quelwert als string typisiert ist.
  • satisfies string: Kompiliert erfolgreich.
  • Intersektionsmarke: Von tsc abgelehnt.
  • unique symbol Marke: von tsc abgelehnt.
  • Zod-Marke: von tsc abgelehnt, außerdem wird eine rohe Benutzer-ID zur Laufzeit von parse abgelehnt.
  • Anders ausgedrückt: Die drei Optionen, die Menschen oft als Eingabe ihrer IDs betrachten, helfen bei diesem Fehler nicht, während die drei echten Marken ihn verhindern, wenn sie ordnungsgemäß verwendet werden.

    Die Vergleichssituation in Ihrem eigenen Projekt nachbilden

    Fügen Sie die sechs Kodierungen in eine Datei wie src/ids.ts ein, fügen Sie für jede eine ungültige deleteInvoice(userId)-Aufruf hinzu und führen Sie den Compiler ohne Ausgabe aus:

    pnpm exec tsc --noEmit
    

    Dann nehmen Sie eine Benutzer-ID und führen Sie sie durch das Zod-Schema – so, wie ein gestohlener oder falsch übermittelter Wert aus einer Anfrage kommen würde:

    InvoiceId.parse(String(userId));
    

    Falls diese Parse-Aktion erfolgreich ist, handelt es sich bei der Marke um einen einfachen Label ohne dahinterliegendes Prädikat.

    Testen Sie die Branding-Funktion nicht, indem Sie direkt neben der Definition id as InvoiceId schreiben. Ein Cast wird immer kompiliert, sodass dieser Test nichts beweist.

    Ausfindig machen der bereits schädlichen Casts

    Brands sind nur so stark wie die Anzahl der Stellen, an denen sie umgangen werden. Suchen Sie nach direkten Casts:

    rg "as InvoiceId|as UserId" src app
    

    Eine lange Liste bedeutet, dass das Branding größtenteils nur dekorativ ist. Beheben Sie zunächst die Konstruktoren sowie die Verarbeitung der Grenzwerte, bevor Sie weitere branded Typen einführen.

    Was der Prüfer garantieren kann und was nicht

    Brands sind „Phantome“: Die erzeugte JavaScript-Datei bleibt weiterhin eine string. Der Prüfer schützt Sie an der Aufrufstelle nur dann, wenn der Wert niemals über as InvoiceId weitergeleitet wurde und auch nie durch eine Funktion ging, die eine einfache string entgegennimmt und das Branding ohne Überprüfung zurückgibt.

    satisfies bleibt die häufigste falsche Marke in Rezensionen. Es ist ein gutes Werkzeug für seine Aufgabe, aber nominale Typisierung ist nicht dessen Funktion.

    Template-Literal-Typen wie `inv_${string}` verhalten sich etwas wie nominale Typen und haben den Vorteil, dass der Präfix bereits im Typ selbst dokumentiert wird. Sie funktionieren nicht, wenn die IDs UUIDs ohne Präfix sind. Anpassen Sie den Typ an die Daten – nicht die Datenbank an den Typ.

    Die in der Praxis am besten funktionierende Trennung besteht darin, Zod-Brands an der öffentlichen Grenze zu verwenden und Intersection-Brands innerhalb der Anwendung. Parsen Sie einmal, wenn die Daten eintreffen – beispielsweise in einem Request-Handler – und lassen Sie den branded Type die Garantie weitergeben. Jedes erneute Parsen auf dem Weg zwischen einer Route wie /invoices und einem Hintergrundprozess führt nur zu zusätzlichen Kosten. Eine Möglichkeit, diese Grenze zentral zu steuern, finden Sie unter Guarding the Express Boundary with One Zod Middleware.

    Welche Kosten entstehen durch Branding?

    • Konstruktoren. Jedes Intersection-Brand benötigt einen. Zwei ID-Typen bedeuten zwei kleine Funktionen, nicht zwanzig.
    • Falsches Vertrauen. Ein einzelner as InvoiceId, der direkt nach JSON.parse platziert wird, hebelt die Schutzmaßnahmen für alles danach stillschweigend auf.
  • Laufzeit-Parsing. Zod erfüllt zwei Funktionen gleichzeitig – Validierung und Markierung – und man zahlt für jede Analyse beim Eingang. Das lohnt sich an der öffentlichen Schnittstelle, ist aber in der Regel zu aufwendig für interne Vorgänge, sobald die Daten bereits überprüft wurden. Auf häufig genutzten Pfaden sollte man die Kosten messen.
  • Der Nutzen. Ein illegaler Aufruf, der kompiliert werden kann, führt dazu, dass eine Zeile gelöscht wird, die nicht wiederhergestellt werden kann. Im Vergleich kostet ein fehlgeschlagener tsc-Aufruf nichts – und das ist der gesamte Wert dieses „Phantomfeldes“.
  • Eine realistische Fehlerursache und die funktionierende Lösung

    Betrachten Sie ein internes Support-Tool, in dem sowohl UserId als auch InvoiceId als type X = string deklariert wurden. Eine Anzeige zu einem Benutzer enthält die Benutzer-ID in seiner URL, und eine Löschaktion auf dieser Anzeige liest die ID aus der URL und gibt sie an deleteInvoice weiter. Der Code kompiliert, und stattdessen verschwindet ein Benutzerdatensatz anstelle einer Rechnung.

    Die verlockende Lösung besteht darin, die Parameter umzubenennen, um die Absicht klarer zu machen. Das hilft jedoch nicht: Der nächste nachlässige Aufruf wird genauso problemlos kompiliert.

    Die wirkliche Lösung besteht darin, innerhalb der Anwendung eine Markierung mit einem Schnittmenge-Typ sowie einem Validierungskonstruktor zu verwenden:

    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;
    }
    

    Und an der HTTP-Grenze ein Zod-Schema, das sowohl validiert als auch markiert:

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

    Nach der Änderung erhält die Löschschaltfläche in einer Rechnungszeile ihre ID über asInvoiceId von einem Feld, das tatsächlich die Rechnungs-ID enthält. Der Benutzeroberflächenbildschirm kann die Benutzer-ID in seiner URL beibehalten, da dieser Bildschirm sich auf den Benutzer bezieht. Die Typisierung hätte den ursprünglichen Hilfsfunktionen Abhilfe geschaffen; ebenso eine klarere Beschriftung. Beides hatte das Team nicht.

    Eine letzte Überprüfung: Eine Suche nach as InvoiceId im Quellcode sollte fast nichts ergeben, und jeder Treffer sollte nachvollziehbar sein.

    Die Überprüfung wiederholbar machen

    Eine kurze, wiederholbare Überprüfungsroutine verhindert, dass diese Ergebnisse zur Folklore werden. Beginnen Sie damit, die Tool-Versionen aufzunehmen, da sich das Verhalten zwischen den Hauptversionen ändern kann. Die Referenzkonfiguration für diesen Vergleich war eine kleine App zur Verwaltung von Rechnungen mit vier Routen auf Node 24, TypeScript 7 und Next.js 16.3; überprüfen Sie die Versionen in Ihrem eigenen Projekt, bevor Sie die Ergebnisse vergleichen.

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

    Falls eine Hauptversion von der erwarteten abweicht, zögern Sie, bevor Sie späteren Ergebnissen vertrauen. Starten Sie anschließend die App und testen Sie die betroffenen Routen:

    pnpm exec next dev
    

    Besuchen Sie /, /invoices, /invoices/1, /settings sowie erneut /invoices unter Aktivierung der Protokollierung in den DevTools, damit Sie sehen können, welches ID jede Seite tatsächlich in ihrer URL enthält.

    Zum Schluss führen Sie den Typprüfer in einer für Skripte geeigneten Form aus und überprüfen Sie den Abbruchstatus:

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

    Ein Ausgabecode von null ist kein Beweis dafür, dass das Produkt korrekt ist. Es bedeutet lediglich, dass die Kompilierungsphase nichts gefunden hat, und das Verhalten zur Laufzeit muss weiterhin überprüft werden. Es hilft auch, für jeden fehlgeschlagenen Versuch eine Zeile anzugeben (“Versucht habe ich X, wurde aber weiterhin Y angezeigt”), zusammen mit den Versionen und dem Ausgabeinhalt, damit die nächste Person denselben Fehler nicht wiederholt.

    Häufige Ursachen für Misserfolge

    • as InvoiceId direkt nach JSON.parse: Dadurch wird aus dem Brand ein Theaterstück.
    • satisfies string wird in der Überprüfung so behandelt, als handele es sich um einen Brand – tatsächlich wird dabei nur die Konformität geprüft.
    • Ein Präfix in Form eines Template-Literals vor einer UUID-Spalte, gefolgt davon, dass jemand das Präfix den gespeicherten Daten hinzufügt, damit der Typ passt. Das sollte rückgängig gemacht werden. Markieren Sie stattdessen den parsierten Wert als Brand, anstatt die Datenbank entsprechend dem Typ zu ändern.
  • Ein Konstruktor wie asInvoiceId, der aus einer Barrel-Datei exportiert wird, sodass er jeder Funktion zur Verfügung steht, die die Validierung überspringen möchte.
  • Checkliste vor dem Aufruf einer typisierten ID

    • Sie ist nicht als type FooId = string deklariert.
    • satisfies string ist nicht ihre einzige Überprüfungskondition.
    • Ein Konstruktor oder eine Zod-Parse schützt sie an der Grenze.
    • deleteInvoice(userId) führt bei tsc zu einem Fehler.
    • Die Ergebnisse der Suche nach as InvoiceId ergeben eine kurze Liste, die man begründen kann.

    Auch der Kontext spielt eine Rolle. Es ist nicht notwendig, jede Zeichenkette im Repository zu kennzeichnen. Kennzeichnen Sie nur die IDs, die Daten zerstören oder offenlegen können: Löschvorgänge, Rückerstattungen und Identitätsdiebstahl sind gute Kandidaten. Wenn am Ende fünfzig Kennzeichnungen vorhanden sind, handelt es sich eher um Dekoration als um Schutz.

    Eine kompakte Sammlung von Befehlen umfasst die laufenden Überprüfungen, einschließlich der Suche nach verbleibenden Aliasen für einfache Zeichenketten-IDs:

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

    Der ungültige Aufruf deleteInvoice(userId) gehört in eine Testdatei, von der erwartet wird, dass sie bei der Typüberprüfung fehlschlägt (zum Beispiel mit einem @ts-expect-error-Kommentar darüber), niemals in Produktionscode wie lib/delete.ts.

    Probieren Sie es in Ihrer Codebasis aus

    Schreiben Sie den illegalen Aufruf deleteInvoice(userId) neben dem tatsächlich verwendeten Lösch-Hilfsfunktion, an einer Stelle, die vom Compiler überprüft wird. Wenn tsc still bleibt, sind Ihre IDs Kommentare. Konvertieren Sie InvoiceId in ein Intersection-Brand und überprüfen Sie, ob der Aufruf rot angezeigt wird. Fügen Sie einen Validierungskonstruktor oder ein Zod-Brand an der HTTP-Grenze hinzu und stellen Sie sicher, dass ein roher Wert wie „usr_123“ zu einem Fehler führt. Suchen Sie anschließend nach as InvoiceId und rechtfertigen Sie jeden Treffer im Pull Request oder entfernen Sie ihn.

    Wichtige Erkenntnisse

    • Alias, as const und satisfies erzeugen keine eigenständigen Typen, daher können sie eine falsch verwendete ID nicht verhindern.
    • Intersection- und unique symbol-Brands sorgen dafür, dass der Compiler falsche Aufrufe ablehnt; Zod-Brands fügen eine Laufzeitprüfung für Werte hinzu, die durch parse gelangen.
  • Jede Marke verfügt über einen Notausgang in as. Behalten Sie die Umwandlungen innerhalb kleiner Validierungskonstruktoren und prüfen Sie den Rest.
  • Analysieren und kennzeichnen Sie einmal an der Grenze, übertragen Sie die statische Marke nach innen und reservieren Sie das Markieren für IDs, deren Fehlgebrauch irreversible Schäden verursacht.