Branded IDs
TypeScript branded types give same-shaped ID strings distinct compile-time identities, so the type system catches a whole class of mix-up bugs.
So far the chapter has typed the shape of values: discriminated unions, state-machine transitions, exhaustive consumers. Inside those values, every userId, invoiceId, and sessionId has been a plain string. This lesson types their identity.
Picture one function that fetches an invoice by ID and another that fetches a user. Both take a string and run a query:
declare function getInvoice(invoiceId: string): Promise<Invoice>;declare function getUser(userId: string): Promise<User>;
declare const currentUser: { id: string; name: string };
// Compiles. The type system can't tell these strings apart.await getInvoice(currentUser.id);Both parameters are string, so the structural type system sees two strings and accepts the call. It compiles, the query runs, and the wrong row comes back: if some invoice’s primary key happens to match the user’s ID, that invoice loads; if none does, you get null and a confusing 404. The user opens their dashboard and sees someone else’s invoice.
The fix isn’t more care at the call site. It’s making UserId and InvoiceId distinct types even though they share one runtime shape, which is what branding does. Once the IDs are branded, getInvoice(currentUser.id) fails to compile and the whole bug class disappears.
Structural vs. nominal typing
Section titled “Structural vs. nominal typing”Two string-typed parameters are interchangeable because of the kind of type system TypeScript is.
TypeScript is structural. Structural typing compares types by shape, so any two aliases with the same underlying shape are interchangeable. type Email = string and type UserId = string are both, structurally, just string: pass an Email where a UserId is expected and TypeScript accepts it, because the shapes match and the names are decoration.
Languages like Java, Rust, and Swift are nominal instead: a UserId is distinct from a string by name, regardless of shape. TypeScript has no nominal type UserId = string syntax. The workaround is the brand : attach a field that exists only at compile time and carries a unique label, giving the type a name the compiler can check and reject mismatches against.
In concrete terms, a branded UserId is just a string at runtime. At compile time it’s a string with an extra __brand: 'UserId' property attached by intersection. JavaScript strings can’t carry extra properties, so this one is phantom: it lives in the type system, never in the runtime value. The compiler still treats string & { __brand: 'UserId' } and string & { __brand: 'InvoiceId' } as distinct types, because their phantom labels differ, even though both erase to string once the code runs.
to cross the lanes.
The brand declaration: a string with a phantom field
Section titled “The brand declaration: a string with a phantom field”The course’s default form is the string-intersection brand: two lines per brand, one phantom field, nothing at runtime. Here are two branded IDs and the call site where the compiler refuses the cross-call.
type UserId = string & { readonly __brand: 'UserId' };type InvoiceId = string & { readonly __brand: 'InvoiceId' };
declare function getUser(id: UserId): Promise<User>;declare function getInvoice(id: InvoiceId): Promise<Invoice>;
declare const currentUserId: UserId;declare const currentInvoiceId: InvoiceId;
await getUser(currentUserId);await getInvoice(currentInvoiceId);await getInvoice(currentUserId);The phantom property. string & { readonly __brand: 'UserId' } is an intersection type: the value is a string and has a __brand field. A primitive string can’t carry a property, so the field is phantom, existing only in the type system. The label is a literal ('UserId', not string) so it becomes part of the type’s identity, which is what makes UserId and InvoiceId non-assignable. readonly keeps any code that surfaces the phantom field from reassigning the label.
type UserId = string & { readonly __brand: 'UserId' };type InvoiceId = string & { readonly __brand: 'InvoiceId' };
declare function getUser(id: UserId): Promise<User>;declare function getInvoice(id: InvoiceId): Promise<Invoice>;
declare const currentUserId: UserId;declare const currentInvoiceId: InvoiceId;
await getUser(currentUserId);await getInvoice(currentInvoiceId);await getInvoice(currentUserId);Two distinct types, same runtime shape. Both erase to string at runtime, but their literal labels ('UserId' vs 'InvoiceId') give them distinct compile-time identities. That is the entire point: two named types the compiler can keep apart.
type UserId = string & { readonly __brand: 'UserId' };type InvoiceId = string & { readonly __brand: 'InvoiceId' };
declare function getUser(id: UserId): Promise<User>;declare function getInvoice(id: InvoiceId): Promise<Invoice>;
declare const currentUserId: UserId;declare const currentInvoiceId: InvoiceId;
await getUser(currentUserId);await getInvoice(currentInvoiceId);await getInvoice(currentUserId);The compile error. getInvoice expects an InvoiceId but received a UserId, so the cross-call no longer compiles. The diagnostic reads Argument of type 'UserId' is not assignable to parameter of type 'InvoiceId' — both labels appear in the message, so you read the mix-up straight off the error.
The brand factory and helper sit in lib/branded.ts, per the project’s file conventions. The per-entity types UserId, OrgId, InvoiceId, and SessionId can live there as global brands, or move next to the database schema in a later unit. For this lesson, treat lib/branded.ts as their home.
The brand factory: the only place as is allowed
Section titled “The brand factory: the only place as is allowed”You now have UserId and InvoiceId as distinct types that the compiler won’t accept a bare string for. But every value that enters your code from outside, a database row, a request body, a URL segment, a localStorage read, arrives as a plain string. Some named place has to cast it from string to UserId. That place is the brand factory .
Start with the simplest possible factory, a one-liner:
export const userId = (value: string): UserId => value as UserId;export const invoiceId = (value: string): InvoiceId => value as InvoiceId;The minimal shape. The factory’s only job is to make as UserId legal in one named place. Downstream code never asserts; it imports the factory and calls it.
import { z } from 'zod';
export const UserId = z.uuid().brand<'UserId'>();export type UserId = z.infer<typeof UserId>;export const userId = (value: string): UserId => UserId.parse(value);The production form, and what you’ll actually write. The factory wraps a Zod parse: the value is validated as a UUID at the seam, then re-branded by .brand<'UserId'>(). The schema, the type, and the factory share the name UserId, so one identifier carries the validator, the inferred output type, and the seam that produces the type’s values.
Two naming conventions pin the pair together. The type UserId is PascalCase because it’s a type; the factory userId is camelCase because it’s a value. At the call site they read naturally: const id = userId(row.id) returns a UserId.
The rule at the heart of the pattern: as <Brand> lives only inside the factory. Every other reference to a branded ID goes through it, so someString as UserId outside lib/branded.ts is a code-review failure. The safety comes from one named validation seam; bypass it and the bug class returns.
The unique-symbol form: collision-proof brands for libraries
Section titled “The unique-symbol form: collision-proof brands for libraries”A second declaration form replaces the literal-typed __brand field with a symbol-keyed phantom property:
declare const userIdBrand: unique symbol;type UserId = string & { readonly [userIdBrand]: never };A unique symbol is guaranteed unique to its declaration, so no other module can produce a colliding property, even one that also uses a __brand: 'UserId' literal. The cost is a wordier declaration; the call site is unchanged.
Reach for it when the brand leaves the package: publishing a library whose branded types round-trip through code you don’t control, or composing a brand with declare module augmentations on third-party types. For internal web-app code, the string-intersection form stays the course’s default.
Brands vanish over the wire, so re-brand at the factory
Section titled “Brands vanish over the wire, so re-brand at the factory”The brand lives in your codebase, not in the value. Serialize a UserId to JSON and the phantom property is gone, because JSON.stringify only sees the underlying string. The receiving end of a fetch, a Server Action response, a database read, or a localStorage get receives a plain string.
So a brand is a compile-time tool, not a runtime contract: it never validates a value crossing a network boundary. What restores the brand is the factory on the receiving side, the same one you already wrote, called once on the incoming string. Treat every spot where a value re-enters typed code from outside as a parse seam, with the factory as the parser. A Server Action that receives a { userId: string } body calls userId(input.userId) at the top, and from that line down the variable is a UserId; a fetch response handler and a database row not already typed by Drizzle do the same.
This is where validation pays off most. If the factory body is a Zod parse, as in the production tab above, the incoming string isn’t just stamped, it’s checked, so a malformed ID from a hostile or buggy client fails at the factory rather than several calls later when the query returns nothing.
Zod integration: the schema is the source of truth
Section titled “Zod integration: the schema is the source of truth”This is the production factory from the CodeVariants block above, and the shape the rest of the course writes.
import { z } from 'zod';
export const UserId = z.uuid().brand<'UserId'>();export type UserId = z.infer<typeof UserId>;export const userId = (value: string): UserId => UserId.parse(value);Three exports, one shared identifier. z.uuid().brand<'UserId'>() is the schema: it parses a string, checks it’s a UUID, and tags the output with a UserId brand. z.infer<typeof UserId> reads that branded type back off the schema, so one declaration yields both the runtime validator and the compile-time type. userId wraps UserId.parse, which throws on an invalid value and returns the branded one otherwise.
The schema and the type share the name UserId, a deliberate exception to the usual rule. Most schemas in the codebase follow the <entity>Schema convention (invoiceSchema, createInvoiceSchema) with the type derived as a separate identifier. Branded IDs collapse that pair, because the schema’s whole purpose is to mint values of the branded type, so one identifier reads cleaner at every import site. Everywhere else, <entity>Schema still holds.
Drizzle integration, in one line
Section titled “Drizzle integration, in one line”Drizzle column types accept a .$type<T>() modifier that overrides the inferred TypeScript type at the schema layer. Declare a primary-key column with .$type<UserId>() and every row read from it returns a UserId, not a string.
id: uuid('id').primaryKey().$type<UserId>();The brand is set once at the schema and propagates through $inferSelect and $inferInsert, so branded IDs flow out of db.select() straight into Server Actions, render functions, and fetch responses with no extra ceremony. Database setup lands in the next unit; naming the seam here means you’ll recognize it.
When to brand a string, and when not to
Section titled “When to brand a string, and when not to”Not every string deserves a brand. The pattern costs declaration noise, factory imports, and a parse step at every boundary, so spend it only where the mix-up bug is real. Three categories qualify:
-
Primary keys.
UserId,OrgId,InvoiceId,SessionId. This is where the mix-up bug from the introduction lives, and where branding pays off most:getInvoice(currentUser.id)stops compiling at every call site. -
External keys with semantic identity.
StripeCustomerId,StripePriceId,StripeSubscriptionId,R2ObjectKey. A third party owns the format, but your code reasons about them as distinct entities, so branding stops you from passing aStripeCustomerIdwhere aStripeSubscriptionIdis expected. -
Secret-typed values.
BearerToken,WebhookSecret,ApiKey. Branding a secret makes it visible at the type level, so a logging wrapper or response-body assembler can be lint-checked against leaking it, andredact(response)knows which fields to strip.
On the other side, a string holding free-form user input, such as an article title, a comment body, a search query, or a display name, is just a string. No ArticleTitle gets confused with a CommentBody, because both are display strings that flow through the same render path, so branding adds declaration noise without preventing a real bug.
The lesson’s final exercise runs you through eight values against this test. Hold the three conditions in mind as you read it.
Exercise: brand OrgId and prove the cross-call fails
Section titled “Exercise: brand OrgId and prove the cross-call fails”The starter brands UserId but leaves OrgId a bare string. A @ts-expect-error directive sits above the cross-call getOrgMembers(someUserId) to assert that line fails to compile. It compiles today, because both arguments are still structurally string, so the directive itself errors with "Unused '@ts-expect-error' directive". Brand OrgId and update its factory to cast accordingly, so the cross-call errors and the directive becomes valid.
The OrgId type is declared as a bare string, so the compiler can't tell a UserId apart from an OrgId. The @ts-expect-error directive on the cross-call currently errors with "Unused '@ts-expect-error' directive" because the line below it compiles. Brand OrgId (and update its factory) so the cross-call fails to type-check, the directive becomes valid, and all the errors go away.
- Fix all errors
Reveal the reference solution
type OrgId = string & { readonly __brand: 'OrgId' };
const orgId = (value: string): OrgId => value as OrgId;Branding OrgId makes it non-assignable to UserId: the literal brand labels differ, even though both erase to string at runtime. The cross-call now fails to type-check (Argument of type 'UserId' is not assignable to parameter of type 'OrgId'), so the @ts-expect-error directive is valid rather than unused and the diagnostic clears.
@ts-expect-error is TypeScript’s idiom for “this line should fail to compile”: it errors while the line below still compiles, and clears once your brand makes it fail.
Exercise: brand it or leave it?
Section titled “Exercise: brand it or leave it?”Here are eight strings you might handle in a real codebase. Sort each one with the senior test from the previous section: brand a value that crosses a schema boundary, carries semantic identity, and could be confused with another value of the same shape.
Each chip is a string value you might handle in a SaaS codebase. Drop each into 'Brand it' if the value crosses a schema boundary, has semantic identity, and could be confused with another value of the same shape — otherwise into 'Leave it'. Drag each item into the bucket it belongs to, then press Check.
The four “brand it” values are a primary key, an external key with semantic identity, and two secrets, all things that match something and could be mistaken for a same-shaped sibling. The four “leave it” values are free-form display strings that nothing ever confuses with another shape, so a brand would only add noise.
External resources
Section titled “External resources”Matt Pocock's workshop entry on the pattern — the canonical reference for branded types in the wider TypeScript community.
Official reference for the `.brand<T>()` method, the inferred output type, and the in/out/inout brand directions.
Atomic Spin's deeper write-up: traditional branding without Zod, Zod's built-in brand, and `refine`-based hybrids.