This article is published in English.
Branded IDs in TypeScript: Which Encodings Actually Stop a Wrong Delete
Six ways to type UserId and InvoiceId compared under one test: which ones make tsc reject deleteInvoice(userId), and where Zod brands add runtime safety.
Picture a helper called deleteInvoice whose first parameter should be an invoice id, and a call site that passes a user id instead. If tsc finishes with exit code 0, whatever id types you declared are nothing more than documentation, and documentation has never prevented a destructive query. This guide runs one simple experiment against six popular ways of encoding UserId and InvoiceId, shows which of them make the compiler refuse the bad call, and ends with a practical policy for where to brand, where to validate at runtime, and how to find the casts that quietly undo it all.
Why two string aliases are the same type
TypeScript's type system is structural. Two object types with the same shape are interchangeable, and two aliases of string are not two types at all: they are the same string under different names. The checker has nothing to distinguish them by.
Branding solves this by attaching a phantom property to the type. The property never exists at runtime; it exists only so that the checker sees UserId and InvoiceId as different shapes. Intersection brands, unique symbol brands, template literal prefixes and Zod's .brand() are all variations of that one trick.
Two operators frequently mistaken for brands are included in the comparison precisely because of that confusion: satisfies and as const. Neither one creates a distinct type.
Keep one fact in mind throughout: once TypeScript is stripped, every encoding below is a plain string. Node has no idea any of these types existed. The only protection you get is what the checker enforces at compile time, plus whatever runtime validation you add explicitly.
The test: one illegal call
Every encoding faces the same call site. A function expects an InvoiceId, a value typed as UserId comes from somewhere else, and the question is whether the compiler objects.
declare function deleteInvoice(id: InvoiceId): Promise<void>;
const userId = getUserId(); // UserId
await deleteInvoice(userId);
1. Plain type aliases
This is what most codebases start with.
type UserId = string;
type InvoiceId = string;
It compiles, and the wrong row disappears. Because both names resolve to string, the checker has no basis for complaint. This is the baseline failure the rest of the options try to fix.
2. Literal types with as const
Here the user id is a literal and the invoice id is a template literal type with a required prefix.
const userId = "usr_123" as const;
type InvoiceId = `inv_${string}`;
This works only in a narrow case. If userId really has the literal type "usr_123", it cannot be assigned to `inv_${string}` and the call is rejected. But real ids come from functions, requests and databases, and a getter like getUserId() usually returns string. The moment that happens, you are back to option 1. Adding as const to something already typed as string does not narrow it into anything useful. Note also that what really does the work here is the template literal type, not as const.
3. satisfies string
This pattern shows up in code review presented as a safety measure.
const userId = getUserId() satisfies string;
It compiles. satisfies verifies that an expression conforms to a type while keeping the expression's own inferred type; it never introduces a new nominal type. It is a useful operator, closer to a spellcheck than a brand, and it offers no protection against passing the wrong id.
4. Intersection brand
Intersecting string with an object that carries a readonly __brand field gives each id a distinct shape.
type UserId = string & { readonly __brand: "UserId" };
type InvoiceId = string & { readonly __brand: "InvoiceId" };
Now tsc rejects deleteInvoice(userId). This is the version that works with no library at all. The cost is that raw strings no longer fit, so each branded type needs a constructor that turns a validated string into the brand:
function asUserId(raw: string): UserId {
if (!raw.startsWith("usr_")) throw new Error("not a user id");
return raw as UserId;
}
That as inside the constructor is the unavoidable hole. If the constructor is public and performs no checking, it becomes a machine for stamping false labels. Validating a prefix is one reasonable check when your ids really have prefixes. If your ids are UUIDs without a prefix, do not change the storage format just to make this check possible; validate whatever is actually true of the value, such as the UUID format or the fact that it was just read from the invoices table.
5. unique symbol brand
Instead of a string-named property, the brand key is a unique symbol declared once.
declare const invoiceBrand: unique symbol;
type InvoiceId = string & { [invoiceBrand]: true };
The compiler refuses the bad call exactly as in option 4. Because the symbol is declared in one module, it is a little harder for another file to forge the brand by writing an object type with the same key. The trade-off is readability: the pattern takes more explanation in a pull request than the __brand version.
6. Zod brand
Zod can attach a brand to the type it infers and, unlike every option above, also check the value at runtime.
const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
type InvoiceId = z.infer<typeof InvoiceId>;
The call with a Zod-branded UserId fails type checking, and parsing usr_123 through the invoice schema fails at runtime because the startsWith("inv_") rule rejects it. That runtime check is what the purely static encodings cannot provide: a value that came from an untrusted edge, even one already mislabeled, is caught when it passes through parse. A manual as InvoiceId elsewhere still bypasses Zod entirely, so the protection holds only for values that actually go through the schema.
Scorecard
- Plain aliases: compile, wrong delete goes through.
as const: compiles as soon as the source value is typedstring.satisfies string: compiles.- Intersection brand: rejected by
tsc. unique symbolbrand: rejected bytsc.- Zod brand: rejected by
tsc, and a raw user id is also rejected at runtime byparse.
In other words, the three options people often treat as typing their ids do nothing for this bug, and the three genuine brands stop it when used honestly.
Reproducing the comparison in your own project
Put the six encodings into something like src/ids.ts, add the illegal deleteInvoice(userId) call for each one, and run the compiler without emitting output:
pnpm exec tsc --noEmit
Then take a user id and push it through the Zod schema, the way a stolen or mixed-up value would arrive from a request:
InvoiceId.parse(String(userId));
If that parse succeeds, the brand is a label with no predicate behind it.
Do not test branding by writing id as InvoiceId right next to the definition. A cast always compiles, so that test proves nothing.
Finding the casts that already undermine you
Brands are only as strong as the number of places that bypass them. Search for direct casts:
rg "as InvoiceId|as UserId" src app
A long list means the brand is mostly decoration. Fix the constructors and the boundary parsing before introducing more branded types.
What the checker can and cannot guarantee
Brands are phantom: the emitted JavaScript is still a string. The checker protects you at a call site only if the value never passed through as InvoiceId and never went through a function that accepts plain string and returns the brand without checking.
satisfies remains the most common false brand in reviews. It is a good tool for what it does, and nominal typing is not what it does.
Template literal types such as `inv_${string}` behave somewhat like nominal types and have the bonus of documenting the prefix in the type itself. They break down when ids are UUIDs without a prefix. Adapt the type to the data, not the database to the type.
The split that holds up well in practice is Zod brands at the public boundary and intersection brands inside the application. Parse once when data enters, for example in a request handler, and let the branded type carry the guarantee inward. Re-parsing at every hop between a route like /invoices and a background worker only adds cost. For one way to centralize that boundary, see guarding the Express boundary with one Zod middleware.
What branding costs
- Constructors. Each intersection brand needs one. Two id types means two small functions, not twenty.
- False confidence. A single
as InvoiceIdplaced right afterJSON.parsesilently cancels the protection for everything downstream. - Runtime parsing. Zod does double duty, validating and branding, and you pay for a parse on entry. That is worth it at the public boundary and usually too heavy for internal hops once the data has already been checked. On a hot path, measure the cost.
- The payoff. One illegal call that compiled can remove a row you cannot restore. A failing
tscrun costs nothing by comparison, and that is the entire value of the phantom field.
A realistic failure and the fix that works
Consider an internal support tool where both UserId and InvoiceId were declared as type X = string. A screen about a user has the user id in its URL, and a delete action on that screen reads the id from the URL and passes it to deleteInvoice. It compiles, and a user record vanishes instead of an invoice.
The tempting fix is renaming parameters to make the intent clearer. It does not help: the next careless call site compiles just as happily.
The fix that holds is branding inside the application with an intersection type and a validating constructor:
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;
}
And, at the HTTP boundary, a Zod schema that both validates and brands:
const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
After the change, the delete button on an invoice row obtains its id through asInvoiceId from a field that actually holds the invoice id. The user screen can keep the user id in its URL, because that screen is about the user. The type would have caught the original helper; so would clearer labeling. The team had neither.
A final sanity check: a search for as InvoiceId in the source should return almost nothing, and every hit should be defensible.
Making the check repeatable
A short, repeatable verification routine keeps these results from turning into folklore. Start by recording the tool versions, since behavior can shift between major releases. The reference setup for this comparison was a small four-route invoices app on Node 24, TypeScript 7 and Next.js 16.3; check the versions in your own tree before comparing results.
node -v
pnpm exec tsc -v
pnpm exec next --version
If a major version differs from what you expect, pause before trusting later results. Then start the app and exercise the routes involved:
pnpm exec next dev
Visit /, /invoices, /invoices/1, /settings and /invoices again with log preservation turned on in DevTools, so that you can see which id each screen actually carries in its URL.
Finally, run the type checker in a script-friendly form and inspect the exit status:
pnpm exec tsc --noEmit --pretty false
echo $?
An exit code of zero is not proof that the product is correct. It only means the compile-time layer found nothing, and runtime behavior still needs to be checked. It also helps to note one line per failed attempt ("tried X, still saw Y") alongside the versions and output, so the next person does not repeat it.
Common ways brands get defeated
as InvoiceIddirectly afterJSON.parse: the brand becomes theater.satisfies stringaccepted in review as if it were a brand: it only checks conformance.- A template literal prefix on a UUID column, followed by someone adding the prefix to the stored data so the type fits. Revert that. Brand the parsed value instead of changing the database to suit a type.
- A constructor such as
asInvoiceIdexported from a barrel file, making it trivially available to any module that wants to skip validation.
Checklist before calling an id typed
- It is not declared as
type FooId = string. satisfies stringis not its only gate.- A constructor or a Zod parse guards it at the boundary.
deleteInvoice(userId)failstsc.- The results of searching for
as InvoiceIdform a short list you can justify.
Scope matters too. There is no need to brand every string in the repository. Brand the ids that can destroy or expose data: delete, refund and impersonation paths are good first candidates. If you end up with fifty brands, you are decorating rather than protecting.
A compact set of commands covers the ongoing checks, including a search for leftover plain-string id aliases:
pnpm exec tsc --noEmit
rg "as InvoiceId" src
rg "type \w+Id = string" src
The illegal deleteInvoice(userId) call belongs in a test file that is expected to fail type checking (for instance with a @ts-expect-error comment above it), never in production code like lib/delete.ts.
Try it on your codebase
Write the illegal deleteInvoice(userId) call beside the delete helper you actually use, somewhere the compiler checks. If tsc stays quiet, your ids are comments. Convert InvoiceId to an intersection brand and confirm the call turns red. Add a validating constructor or a Zod brand at the HTTP boundary and confirm that a raw "usr_123" throws. Then search for as InvoiceId and either justify each hit in the pull request or remove it.
Key takeaways
- Aliases,
as constandsatisfiesdo not create distinct types, so they cannot stop a mixed-up id. - Intersection and
unique symbolbrands make the compiler reject the wrong call; Zod brands add a runtime check for values that pass throughparse. - Every brand has an escape hatch in
as. Keep casts inside small validating constructors and audit the rest. - Parse and brand once at the boundary, carry the static brand inward, and reserve branding for ids whose misuse causes irreversible damage.