Branded IDs
Using TypeScript branded types to 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 been about the shape of values: discriminated unions for variants, transitions for state machines, exhaustiveness for the consumers that read them. Inside those variants, every userId, invoiceId, and sessionId has been a plain string. This lesson types the identity of those strings.
Picture a function that fetches an invoice by ID, and another that fetches a user. Both take a string. Both run a query. The signatures look like this:
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 happens to have a primary key that matches the user’s ID, the query returns that invoice; if none does, it returns null and a confusing 404. In production, the user opens their dashboard and sees someone else’s invoice.
The fix isn’t to be more careful at the call site. The fix is to make UserId and InvoiceId distinct types even though they’re the same shape at runtime, which is what branding does. Once the IDs are branded, getInvoice(currentUser.id) fails to compile, the bug class disappears, and the rest of the codebase reads the brand without ceremony. This lesson walks through the four pieces of the pattern: the type declaration, the factory that creates branded values, the Zod integration at the parse seam, and the judgment call between IDs that earn a brand and strings that don’t.
Structural vs. nominal: the model the brand lives in
Section titled “Structural vs. nominal: the model the brand lives in”To understand why two string-typed parameters are interchangeable, you need to know what kind of type system TypeScript is. The answer shapes every part of this lesson.
TypeScript is structural. Structural typing matches types by shape, not by name. If two type aliases boil down to the same underlying shape, they are interchangeable as far as the compiler is concerned. So 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.
Other languages such as Java, C#, Rust, and Swift work differently. They are nominal : a UserId is distinct from a string because it has a different name, regardless of shape. TypeScript has no nominal-type keyword. There is no nominal type UserId = string syntax. The workaround is the brand : you attach a phantom field to the type, a field that exists only at compile time, whose value carries a unique label. That label gives the type a name the compiler can check against, so it can reject a value with the wrong label.
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 via an intersection. JavaScript strings can’t carry extra properties, so the property is phantom: it exists 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 the moment the code runs.
to cross the lanes.
The figure captures the idea the rest of the lesson builds on: at runtime the brand is invisible, and at compile time it gives the type a name. The declaration syntax, the factory, and the wire boundary are all machinery in service of that one idea.
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, and nothing that exists 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 must be a string and have a __brand field. JavaScript can’t actually carry a property on a primitive string, so the field exists only in the type system. The readonly modifier and the literal-typed value ('UserId' rather than just string) are deliberate. Making the label a literal puts it into the type’s identity, so a UserId and an InvoiceId are no longer assignable to each other.
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 when the code runs, but the literal brand labels ('UserId' vs 'InvoiceId') give them distinct identities at compile time. That distinction is the entire point: the compiler now has two named types it 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. The diagnostic reads Argument of type 'UserId' is not assignable to parameter of type 'InvoiceId'. Both literal brand labels appear in the message, so the engineer can read the mix-up straight off the error. The cross-call bug no longer compiles.
A few mechanical notes on the declaration. string & { ... } is an intersection type. The previous chapter introduced the operator, and here it serves a new role: the intersection says the value must satisfy both sides. JavaScript can’t actually attach a property to a primitive string, but the type still requires the property to exist. The compiler reasons about the phantom field, and the runtime never sees it.
The readonly modifier and the literal-typed value ('UserId', not just string) are both deliberate. The literal type is what makes two brand labels non-assignable: change 'UserId' to 'InvoiceId' and you get a structurally different type, because the literal values don’t match. The readonly modifier adds a second layer of safety, so that even if some pathological code surfaced the phantom property, it couldn’t be reassigned. This readonly-literal phantom is the form the rest of the course uses.
Where do the type declarations live? Per the project’s file conventions, the brand factory and helper sit in lib/branded.ts. The per-entity types UserId, OrgId, InvoiceId, and SessionId can live there too for global brands, or co-locate with the database schema once Unit 5 lands and the schema becomes the source of truth. For this lesson, treat lib/branded.ts as their central home.
The brand factory: the only seam where as is allowed
Section titled “The brand factory: the only seam where as is allowed”You now have UserId and InvoiceId as distinct types, and the compiler refuses to assign a bare string to either. That is exactly the point. But every value that enters your code from outside, whether a database row, a request body, a URL segment, or a localStorage read, arrives as a plain string. Some named place has to perform the cast from string to UserId. That place is the brand factory .
The simplest possible factory is 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 whole job is to make as UserId legal in one named place. Downstream code never asserts; it imports the factory and calls it. The arrow function bound to const matches the course’s default function form. There is no function keyword, because the factory is a thin wrapper, not a type-guard signature.
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. 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 values of that type. Zod authoring lands in depth in a later unit; here the point is the API surface.
Two things are happening across those tabs. The bare as UserId cast is the simplest factory that satisfies the pattern’s contract: one place where as is allowed, with all downstream code going through the function. The production shape is what you’ll actually write. There the factory body becomes a validator, so the seam doesn’t just trust the string, it checks the string before stamping it with the brand.
A few conventions are worth pinning down. The type is UserId, in PascalCase because it’s a type. The factory function is userId, in camelCase because it’s a value. The pair reads naturally at the call site: const id = userId(row.id) returns a UserId. Keep this same-name-pair convention.
The rule at the heart of the pattern is this: as <Brand> lives only inside the factory. Every other reference to a branded ID goes through the factory, and a call to someString as UserId outside lib/branded.ts is a code-review failure. The pattern’s safety comes from keeping validation at one named seam, so bypassing the seam brings the bug class back.
The unique-symbol form: when the brand crosses a package boundary
Section titled “The unique-symbol form: when the brand crosses a package boundary”There is a second brand-declaration form you’ll see in the wild. It replaces the literal-typed __brand field with a symbol-keyed phantom property:
declare const userIdBrand: unique symbol;type UserId = string & { readonly [userIdBrand]: never };unique symbol is a TypeScript type that guarantees the symbol is uniquely identifiable. No other module can accidentally produce a property that collides with this one, even if a third-party type also happens to use a __brand: 'UserId' literal field. The trade-off is a little extra verbosity at the declaration; the call-site experience is identical.
Reach for it when the branded type leaves the package boundary. Two cases call for it: publishing a library whose branded types must round-trip through codebases the author doesn’t control, or a brand that must compose with declare module augmentations on third-party types, which the next chapter covers. For ordinary internal SaaS code, the string-intersection form is the course’s default. The unique-symbol form is worth knowing here, and the later chapter returns to it.
Brands erase at the wire boundary
Section titled “Brands erase at the wire boundary”One property of a brand is easy to miss and worth stating plainly: the brand exists in the 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. The wire never carries the brand.
That means brands are a compile-time tool, not a runtime contract. They don’t validate the value as it crosses a network boundary. What validates the value is the brand factory on the receiving side, the same factory you already wrote, called once on the incoming string. The seam re-brands the value after validating it, and from that line forward downstream code holds a UserId again.
An experienced engineer treats every place a branded 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) once at the top, and from that line down the variable is a UserId. A fetch response handler does the same, and so does a row read from the database that wasn’t already typed by Drizzle. This is the round-trip: branded in scope A, plain over the wire, re-branded in scope B.
This is also the seam where validation pays off the most. If the factory body is a Zod parse, as in the production tab from earlier in the lesson, the incoming string isn’t just stamped, it’s checked. A malformed ID from a hostile or buggy client fails right at the factory, rather than several function calls later when the database query returns nothing.
Zod integration: the schema is the source of truth
Section titled “Zod integration: the schema is the source of truth”You saw the production factory in the CodeVariants block above. It’s worth one explicit pass to name what’s happening, since this is 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 identifier shared between them. Let’s walk through what each does.
z.uuid().brand<'UserId'>() creates a Zod schema that parses a string, checks it’s a UUID, and tags the schema’s output type with a UserId brand. The .brand<...>() method is Zod’s own brand machinery, structurally compatible with the string-intersection brand the course defaults to. The output type Zod produces is string & z.$brand<'UserId'>, which behaves the same way for the compiler.
z.infer<typeof UserId> reads the inferred output type off the schema. Because the schema was branded, the inferred type carries the brand. That gives you one declaration and two artifacts: the runtime validator and the compile-time type.
UserId.parse(value) runs the schema on the incoming value, throwing if it’s invalid and returning the branded value if it isn’t. The factory wraps that one call.
The schema and the type share the name: both are exported as UserId. This is a deliberate exception to the usual naming rule. Most Zod 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 here the brand and the schema are the same concept. The schema’s whole purpose is to mint values of the branded type, so one identifier for the pair reads cleaner at every import site. The <entity>Schema rule still holds everywhere else; brands are the one exception.
Zod authoring at depth, covering error handling, safeParse patterns, and schema composition, lands in a later unit. This section shows just enough of the surface to wire brands to Zod; the full treatment comes later.
Drizzle integration, in one line
Section titled “Drizzle integration, in one line”When the database schema lands (next unit), Drizzle column types accept a .$type<T>() modifier that overrides the inferred TypeScript type at the schema layer. A primary-key column declared with .$type<UserId>() makes every row read from that column return a UserId, not a string.
id: uuid('id').primaryKey().$type<UserId>();The result is that branded IDs flow out of db.select() straight into Server Actions, render functions, and fetch responses, so every downstream consumer reads the brand without any extra ceremony. The brand is set once at the schema and propagates through $inferSelect and $inferInsert. Database setup and the column-customization API land in the next unit; naming the seam here means you’ll recognize it when you reach it.
Where brands earn their weight, and where they don’t
Section titled “Where brands earn their weight, and where they don’t”Not every string deserves a brand. The pattern has a cost in declaration noise, factory imports, and an extra parse step at every boundary, and you only want to pay that cost where the bug class actually exists. Three categories earn the reach:
-
Primary keys.
UserId,OrgId,InvoiceId,SessionId. Recall the original mix-up bug from the introduction:getInvoice(currentUser.id)compiled because both arguments werestring. Branding the primary keys makes the cross-call impossible to compile at every call site in the codebase. This is where brands pay off the most. -
External keys with semantic identity.
StripeCustomerId,StripePriceId,StripeSubscriptionId,R2ObjectKey. The third party owns the value’s format, but your application reasons about them as distinct entities. Deep inside webhook-handler code, branding prevents passing aStripeCustomerIdwhere aStripeSubscriptionIdwas expected. Stripe billing and R2 object storage land in later units, so the pattern is set up here for them. -
Secret-typed values.
BearerToken,WebhookSecret,ApiKey. Branding a secret makes it visible at the type level, so a logging wrapper or a response-body assembler can be lint-checked against including it by accident. The brand is the type-level handle that letsredact(response)know which fields to strip. The security baseline unit covers the wider story; here the brand is just that handle.
Consider the other side as well. A string that holds free-form user input, such as an article title, a comment body, a search query, or a display name, is just a string. Branding it adds declaration noise without preventing a real bug. No ArticleTitle ever gets confused with a CommentBody in your codebase, because both are display strings that flow through the same render path. For these, the cost of branding always outweighs what little safety it would buy.
The exercise at the end of the lesson 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 below declares UserId correctly but leaves OrgId as a bare string. A @ts-expect-error directive sits above the cross-call getOrgMembers(someUserId), and its job is to assert that the line below it fails to compile. Right now the line compiles, because both arguments are still structurally string, so the directive itself fires with "Unused '@ts-expect-error' directive". Brand OrgId properly, and update the orgId factory to cast accordingly, so the cross-call fails to type-check. Once that line errors, the directive becomes valid and the diagnostic clears.
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 structurally distinct from UserId: the literal brand labels differ, so the two types are non-assignable even though both erase to string at runtime. The cross-call getOrgMembers(someUserId) now fails to type-check (Argument of type 'UserId' is not assignable to parameter of type 'OrgId'), the @ts-expect-error directive is valid rather than unused, and the diagnostic clears.
The way that directive behaves is the whole point of the exercise. @ts-expect-error is the natural TypeScript idiom for “this line should fail to compile”: it complains when the line below doesn’t error, and goes quiet when it does. The starter has the directive in place but the brand is missing, so you fix the brand, the line errors, and the directive becomes valid.
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 into the right bucket using the senior test from the previous section: brand a value when it crosses a schema boundary, has semantic identity, and could plausibly be confused with a different 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.
If you reached for the senior test on every chip and the answer fell out, the pattern has landed. The four “brand it” values are a primary key, an external key with semantic identity, and two secret-typed values. The four “leave it” values are all free-form strings whose only role is display, and none of them get confused with another shape-identical value in any realistic codebase.
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.