DEV Community

Cover image for Preventing Accidental Interchangeability in TypeScript — Branding & the Unique Property Pattern
Ali nazari
Ali nazari

Posted on

Preventing Accidental Interchangeability in TypeScript — Branding & the Unique Property Pattern

TypeScript’s structural type system is convenient and flexible: two types are compatible when their shapes match.

That convenience can become a problem when you want different domain concepts (e.g. UserId vs ProductId) to be treated as distinct even though both are plain strings or numbers.

This post explains two effective patterns for making types nominal-like in TypeScript: branding (tagged types) and the unique property pattern (using unique symbol).

You’ll get practical code, runtime-friendly factories and guards, and guidance about trade-offs and pitfalls.

Problem: structural typing → accidental interchangeability

Example of the problem:

type UserId = string;
type ProductId = string;

function getUser(id: UserId) { /* ... */ }

const pid: ProductId = "p-123";
getUser(pid); // allowed — but this is probably a bug
Enter fullscreen mode Exit fullscreen mode

Because UserId and ProductId are both string, TypeScript treats them as identical.

We need a way to make these types distinct at the type level, without adding runtime overhead or complex class wrappers.


Overview of solutions

  1. Branding (dummy property / intersection) — add a compile-time-only phantom property (commonly __brand) to make the type shape unique. Requires an explicit construction or as cast.

  2. Unique property pattern (unique symbol) — use a unique symbol as a property key to guarantee uniqueness across modules and third-party code.

  3. Runtime-safe factories and type guards — create values and validate them at runtime to avoid as-driven unsafety.

  4. Use wrappers (classes/objects) if you need runtime identity/behaviour.


Simple branding (the classic trick)

A generic Brand utility makes it easy:

type Brand<K, T> = K & { __brand: T };

// Domain-specific tagged types:
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
Enter fullscreen mode Exit fullscreen mode

UserId and ProductId are now structurally different because of the __brand property. You cannot assign one to the other without an explicit cast.

Creating branded values

function makeUserId(s: string): UserId {
  return s as UserId; // only here do we assert; caller must use factory
}

const uid = makeUserId("u-42");
const pid = "p-42" as ProductId;

getUser(uid);       // ok
getUser(pid);       // error (ProductId not assignable to UserId)
Enter fullscreen mode Exit fullscreen mode

Notes:

  • The __brand field is purely a compile-time trick — it does not exist at runtime.

  • Any as cast can subvert the safety; prefer factories or validators.


Unique symbol branding (safer)

Using a unique symbol gives stronger guarantees and prevents collisions:

declare const userIdBrand: unique symbol;

type UserId = string & { readonly [userIdBrand]: true };
Enter fullscreen mode Exit fullscreen mode

This pattern ensures the key is truly unique, so other code cannot accidentally create an identical-looking brand. It’s more robust when publishing libraries or when multiple modules are involved.

Example with factory:

declare const userIdBrand: unique symbol;

type UserId = string & { readonly [userIdBrand]: true };

function makeUserId(s: string): UserId {
  return s as UserId;
}
Enter fullscreen mode Exit fullscreen mode

Because userIdBrand is a unique symbol, the only place that exact property key exists is where it is declared; other code cannot recreate it accidentally.


Runtime factories and type guards (recommended)

Branding alone is compile-time. If you want runtime validation (recommended for input coming from network / user), combine brands with factories and guards.

Factory + Guard example

// branding
type UserId = Brand<string, "UserId">;

function makeUserId(s: string): UserId {
  if (!/^u-\d+$/.test(s)) throw new Error("invalid user id");
  return s as UserId;
}

function isUserId(x: unknown): x is UserId {
  return typeof x === "string" && /^u-\d+$/.test(x);
}

// usage
const raw = getFromNetwork();
if (isUserId(raw)) {
  // raw is now narrowed to UserId
  useUserId(raw);
} else {
  // handle invalid input
}
Enter fullscreen mode Exit fullscreen mode

This pattern enforces domain constraints at runtime and gives you genuine safety when data crosses boundaries.


Branded types with generic utilities

A reusable Brand helper:

type Brand<K, T extends string> = K & { readonly __brand: T };

// make factory creator
function brandFactory<K, T extends string>(tag: T) {
  return {
    of(value: K) { return value as Brand<K, T>; },
  };
}

const UserIdFactory = brandFactory<string, "UserId">("UserId");
type UserId = Brand<string, "UserId">;

const id = UserIdFactory.of("u-1");
Enter fullscreen mode Exit fullscreen mode

This is flexible for many domain IDs. But remember: these branded types are still erased at runtime.

Branded types in unions, generics, collections

Branding usually composes well with arrays, generics and unions:

type OrderId = Brand<string, "OrderId">;
type Id = UserId | OrderId;

function useId(id: Id) {
  // structural checks and discriminants still behave as expected
}

const arr: UserId[] = [UserIdFactory.of("u-1"), UserIdFactory.of("u-2")];
Enter fullscreen mode Exit fullscreen mode

Caveat: when using mapping types or serialization helpers, be aware that the brand does not exist at runtime and will be lost on JSON round-trip.


Serialization and JSON

Because brands are compile-time-only, JSON conversions strip the brand. Always re-validate or re-construct branded values from parsed JSON:

// from server:
const parsed = JSON.parse(payload);
const uid = makeUserId(parsed.id); // re-validate and brand
Enter fullscreen mode Exit fullscreen mode

Never assume a raw string from external input is a properly-branded value.


Best practices

  • Provide small factory functions (makeX) and type guards (isX) for each branded type.

  • Validate inputs at the boundary (HTTP handlers, file parsers) and convert raw data into branded values immediately.

  • Prefer unique symbol for widely-distributed libraries or when collisions might occur.

  • For internal codebases, a simple Brand<K, "Name"> plus disciplined factories is usually sufficient.

  • Document the expected runtime shape and provide examples for consumers.

💡 Have questions? Drop them in the comments!

Top comments (0)