DEV Community

Cover image for Stop Passing string for User IDs. Branded Types Aren't Optional Anymore
Gabriel Anhaia
Gabriel Anhaia

Posted on

Stop Passing string for User IDs. Branded Types Aren't Optional Anymore


A bug ships on a Friday. The function signature reads refund(userId: string, orderId: string, amount: number). The call site passes them in the wrong order. TypeScript compiles. The tests pass. Production discovers it Monday morning, after the support inbox has filled up.

You have seen this bug. Every backend that handles money has a story like it, and the story always ends with somebody saying "how did the type system not catch this." The type system did exactly what it was asked. It checked that two strings were two strings.

TypeScript is structurally typed by design. A UserId and an OrderId are both string underneath, so they are the same type to the compiler. That is the whole problem. The fix is four lines of code most teams still treat as advanced trivia. Effect, Zod 4, ArkType, and Valibot all support first-class branded outputs. Brands stopped being optional.

The Bug Class Brands Delete

Before the fix, the shape of the bug. Here is a refund handler with no brands, written the way most production code still looks.

type User = { id: string; email: string }
type Order = { id: string; userId: string; total: number }

async function refund(
  userId: string,
  orderId: string,
  amount: number,
): Promise<void> {
  const order = await db.orders.findById(orderId)
  if (order.userId !== userId) {
    throw new Error("user does not own this order")
  }
  await db.payments.refund(orderId, amount)
}

// Somewhere in a controller:
const user = await getCurrentUser(req)
const order = await loadOrder(req.params.id)

// Wrong. Compiles. Runs. Refunds the wrong record.
await refund(order.id, user.id, order.total)
Enter fullscreen mode Exit fullscreen mode

The arguments are reversed. The compiler is happy because every value is a string. The ownership check fails, an error gets thrown, and that is the good outcome. If the ids happen to collide in test data, the check passes and the refund moves money against the wrong record.

You can paper over this with naming conventions, controller tests, code review. None of that is what the type system is for. You handed it two strings; it gave you back the answer for two strings.

Hand-Rolled Brand in Four Lines

The whole pattern fits in a single declaration and a smart-constructor function.

declare const __brand: unique symbol
type Brand<T, K extends string> = T & { readonly [__brand]: K }

type UserId = Brand<string, "UserId">
type OrderId = Brand<string, "OrderId">

const UserId = (raw: string): UserId => raw as UserId
const OrderId = (raw: string): OrderId => raw as OrderId
Enter fullscreen mode Exit fullscreen mode

That is the whole mechanism. Brand<T, K> intersects the underlying type with a phantom property keyed by a unique symbol. The symbol exists only at the type level, so a UserId is still just a string at runtime. JSON, network calls, database drivers all see plain strings. Your code sees two distinct types.

The smart constructor does no validation yet. It exists to be the only blessed way to mint a UserId, so you can grep for UserId( and see where strings cross into branded territory. Refactor it into a real validator the moment you have a rule worth checking.

const UserId = (raw: string): UserId => {
  if (!/^usr_[a-z0-9]{12}$/.test(raw)) {
    throw new Error(`invalid UserId: ${raw}`)
  }
  return raw as UserId
}
Enter fullscreen mode Exit fullscreen mode

Now plug the brands into the refund handler.

async function refund(
  userId: UserId,
  orderId: OrderId,
  amount: number,
): Promise<void> {
  const order = await db.orders.findById(orderId)
  if (order.userId !== userId) {
    throw new Error("user does not own this order")
  }
  await db.payments.refund(orderId, amount)
}

// The mistake from before:
await refund(order.id, user.id, order.total)
// Argument of type 'OrderId' is not assignable to
// parameter of type 'UserId'.
// Argument of type 'UserId' is not assignable to
// parameter of type 'OrderId'.
Enter fullscreen mode Exit fullscreen mode

Two compile errors. That category of bug can no longer compile — there is no version of the wrong call site that typechecks, because UserId and OrderId are no longer the same type.

The cost is two lines per id and the discipline of going through the smart constructor. The benefit lands on every future call site that takes a UserId or an OrderId.

The Same Pattern, Bigger Scope

Once brands click, primitives stop looking primitive. An email is not a string, it is an Email. A cents-as-integer amount is not a number, it is Cents. The constructors absorb validation rules that used to be scattered across the codebase as if (!isValid(x)) throw.

type Email = Brand<string, "Email">
const Email = (raw: string): Email => {
  if (!raw.includes("@")) throw new Error(`invalid Email: ${raw}`)
  return raw.toLowerCase().trim() as Email
}

type Cents = Brand<number, "Cents">
const Cents = (raw: number): Cents => {
  if (!Number.isInteger(raw) || raw < 0) {
    throw new Error(`invalid Cents: ${raw}`)
  }
  return raw as Cents
}

function chargeCard(email: Email, amount: Cents): Promise<void> {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

A function that takes Cents cannot be passed dollars by accident. A function that takes Email cannot be passed an unvalidated form input. The brand becomes the proof that the value passed through the validator at least once, and that proof travels with the value through every layer of the system at zero runtime cost.

Haskell and OCaml people call this nominal typing and have been using it for thirty years. TypeScript's structural typing is a feature, but it is the wrong default for ids, currency, and validated strings. Brands give you nominal typing exactly where you want it.

Validator-Emitted Brands

Hand-rolled brands are fine for a small service. The moment you need parsing, you reach for a validator. Every serious TypeScript validator now emits branded outputs. The four worth knowing in 2026 are Effect (3.21), Zod 4 (4.4), ArkType (2.2), and Valibot (1.3), as of April 2026. All four APIs are stable.

Effect

Effect's Brand module is the most explicit of the four. It separates nominal brands (no runtime check) from refined brands (runtime predicate plus type-level brand).

import { Brand } from "effect"

type UserId = string & Brand.Brand<"UserId">
const UserId = Brand.refined<UserId>(
  (raw) => /^usr_[a-z0-9]{12}$/.test(raw),
  (raw) => Brand.error(`invalid UserId: ${raw}`),
)

const id = UserId("usr_abc123def456") // UserId
const bad = UserId("nope")             // throws
Enter fullscreen mode Exit fullscreen mode

UserId.either(raw) returns an Either if you want a non-throwing path. Brands compose via Brand.all, so a PositiveInt can carry both Int and Positive brands. If you are already on Effect, brands fit into the same error pipelines you are using elsewhere.

Zod 4

Zod 4 keeps .brand() minimal and composable. The brand attaches to the schema's output, and the inferred type carries it.

import { z } from "zod"

const UserId = z.string()
  .regex(/^usr_[a-z0-9]{12}$/)
  .brand<"UserId">()

type UserId = z.infer<typeof UserId>
// string & z.$brand<"UserId">

const parsed = UserId.parse("usr_abc123def456") // UserId
const plain: UserId = "usr_abc123def456"
// Type 'string' is not assignable to type '...$brand<"UserId">'.
Enter fullscreen mode Exit fullscreen mode

.brand() in Zod 4 takes a second generic controlling direction ("in", "out", "inout"), which matters when you are also inferring the input type for a form. Default is output-branded. The brand survives .transform(), .optional(), and the rest of the chain.

ArkType

ArkType bakes branding into its string DSL using the # syntax.

import { type } from "arktype"

const UserId = type("/^usr_[a-z0-9]{12}$/#UserId")

type UserId = typeof UserId.infer
// Brand<string, "UserId">

const ok: UserId = UserId.assert("usr_abc123def456")
const no: UserId = "usr_abc123def456" // type error
Enter fullscreen mode Exit fullscreen mode

The #UserId suffix is the brand. Whatever satisfies the regex before the # is branded with the tag after. .assert() validates and narrows in a single call. The same # syntax works on numeric ranges, structural types, and morphs.

Valibot

Valibot uses a pipe-based composition. brand is just another action you push into the pipe.

import * as v from "valibot"

const UserIdSchema = v.pipe(
  v.string(),
  v.regex(/^usr_[a-z0-9]{12}$/),
  v.brand("UserId"),
)

type UserId = v.InferOutput<typeof UserIdSchema>

const id = v.parse(UserIdSchema, "usr_abc123def456") // UserId
const bad: UserId = "usr_abc123def456" // type error
Enter fullscreen mode Exit fullscreen mode

Valibot's selling point is bundle size. The brand action tree-shakes down to almost nothing, which matters in the browser. Same nominal-typing guarantee as the other three, lighter on the wire.

Branded outputs are table stakes now. Pick the validator that fits the rest of your stack; parsing produces a typed value downstream code can trust, and the brand is the proof.

Where Brands Break Down

Brands are a compile-time fiction. The runtime sees plain strings and numbers. That fiction holds inside your TypeScript code and falls apart at every system boundary that does not speak TypeScript.

The first place it breaks is JSON. JSON.parse returns any. The output of await response.json() is an unknown blob of plain strings. Casting it to a type with UserId fields is a lie the compiler will believe and the runtime will not check.

type Order = {
  id: OrderId
  userId: UserId
  total: Cents
}

// Wrong. The cast asserts brands the runtime never validated.
const order = (await fetch("/api/orders/123").then((r) => r.json())) as Order
Enter fullscreen mode Exit fullscreen mode

The second place it breaks is the database. ORMs return string fields typed by the ORM's code generator, not by your branded types. A users.id column comes back as string, not UserId. You have to convert.

The third place is third-party SDKs. Stripe's customer id, Auth0's user id, your queue's message id, all plain strings. Let them flow into your domain typed as string and the brand discipline collapses one layer in.

The fix for all three is the same: a parsing layer. Validate at the edge and brand once. Inside the system, types stay branded; at the edge, a validator produces branded values from raw input. Parse-don't-validate, and brands are what make it pay off.

// At the HTTP boundary:
const OrderShape = z.object({
  id: z.string().brand<"OrderId">(),
  userId: z.string().brand<"UserId">(),
  total: z.number().int().nonnegative().brand<"Cents">(),
})

async function loadOrder(id: OrderId): Promise<z.infer<typeof OrderShape>> {
  const raw = await fetch(`/api/orders/${id}`).then((r) => r.json())
  return OrderShape.parse(raw) // throws or returns a fully-branded Order
}
Enter fullscreen mode Exit fullscreen mode

The same shape works for database hydration. Whatever your data access layer hands you, run it through a parser before it touches domain code. The mapper layer is where every branded type begins its life. After that, the brand follows the value through call stacks, generic containers, and serialization back out.

Cost vs Benefit

Brands are not free. The constructor sits on the call site. Library boundaries need adapter calls when third-party code hands you a raw string that needs to become a branded id. Reading the type definitions takes an extra second the first time.

The places where brands earn their keep:

  • Identifiers. User ids, order ids, tenant ids, message ids, anything where mixing two of the same shape is a bug. This is where the payoff is highest and the cost is lowest.
  • Money. Currency, cents, micro-cents, the difference between a dollar amount and a cent amount. Every fintech bug story has a unit confusion in it somewhere.
  • Validated strings. Emails, URLs, slugs, anything that has a regex on the way in. The brand is the proof the regex ran.
  • Units of measure. Latitude vs longitude, milliseconds vs seconds, meters vs feet. Same shape, different meaning.
  • Trust levels. A RawHtml and a SafeHtml are both strings, but mixing them is an XSS bug. Brands are the cleanest way to encode that distinction at the type level.

Where brands are not worth it: local primitives that never escape a function, display values that get rendered straight to the user, throwaway scripts. The rule of thumb: if a value crosses a function boundary and gets confused with another of the same shape, brand it. If it does not, leave it alone.

Forward Motion

Branded types are the single highest-payoff type system feature TypeScript ships that most production codebases still ignore. The fix takes four lines, and the validators you are already using emit branded outputs the moment you ask. You pay the cost once at the constructor and the parser, and the brand follows the value everywhere it goes after that.

If your codebase has a User, an Order, a Tenant, a Cents, a Millis, a Slug, a Url, an Email, brand them. Start with the ids, because that is where the cost-benefit is most lopsided. Push validators to the edge. Make the smart constructor the only way in. After a week the codebase will tell you which other primitives want a brand, because the compiler will start catching bugs you used to catch in code review.

Open the file Monday morning. Pick the id you most often pass into a function next to another id of the same shape. Brand that one first.


If this was useful

Branded types live inside the larger conversation about making TypeScript's type system carry real meaning. The TypeScript Type System is the deep-dive book in The TypeScript Library — the chapter on brands sits next to the chapters on conditional types, mapped types, template literal types, and the patterns that turn a working type system into a domain-modeling tool.

  • TypeScript Essentials — entry point if you are a working developer who wants to feel confident across Node, Bun, Deno, and the browser: Amazon
  • The TypeScript Type System — the deep-dive on generics, mapped and conditional types, infer, template literals, and brands: Amazon
  • Kotlin and Java to TypeScript — the bridge for JVM developers, variance, null safety, sealed-to-unions, coroutines-to-async: Amazon
  • PHP to TypeScript — the bridge for PHP 8+ developers, sync-to-async paradigm, generics, discriminated unions: Amazon
  • TypeScript in Production — tooling, build, monorepos, library authoring across runtimes, dual ESM/CJS, JSR: Amazon

If you are picking up the language, start with Essentials. If you came from JVM or PHP, start with the bridge that matches you and add The Type System once you want to push further. Production is the one anyone shipping TypeScript at work will end up reading.

The TypeScript Library — the 5-book collection

Top comments (0)