6 min read

Branded Types in TypeScript: Achieving Nominal Typing Without Leaving the Language

Learn how to use TypeScript’s branded (opaque) types to get nominal typing, catch subtle bugs, and keep your codebase safe and expressive.
Branded Types in TypeScript: Achieving Nominal Typing Without Leaving the Language

Introduction

TypeScript’s type system is structural: two types are compatible if they have the same shape. This is powerful, but it also means the compiler can’t distinguish between values that happen to share a structure but represent different concepts.

type UserId = string;
type OrderId = string;

function getUser(id: UserId) { /* … */ }
function getOrder(id: OrderId) { /* … */ }

getUser("order-123"); // ✅ No error – both are just strings

The above code compiles, yet at runtime we’ve passed an OrderId where a UserId was expected. In large codebases this kind of mix‑up can cause hard‑to‑track bugs, especially when IDs, timestamps, or monetary values are all represented by primitive types.

Nominal typing solves the problem by giving each logical type a unique identity that the compiler respects. TypeScript doesn’t have built‑in nominal types, but a simple pattern—branded (or opaque) types—lets us simulate them with zero runtime cost.

In this article we’ll explore:

  • The theory behind branded types.
  • A step‑by‑step implementation that works with any primitive.
  • Real‑world scenarios (API contracts, domain models, and third‑party libraries).
  • Pitfalls, tooling tips, and best‑practice guidelines.

By the end you’ll be able to add nominal safety to your projects without sacrificing the ergonomics of TypeScript’s structural system.


1. The Core Idea: Adding a Phantom Property

A brand is a unique, never‑used property that lives only in the type system. Because it never appears at runtime, it doesn’t affect the emitted JavaScript.

type Brand<K, T> = K & { __brand: T };
  • K – the underlying primitive (e.g., string or number).
  • T – a unique literal type that identifies the brand.

Now we can define a UserId as a branded string:

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

Both UserId and OrderId are still strings at runtime, but the compiler treats them as distinct types:

declare const uid: UserId;
declare const oid: OrderId;

uid = oid; // ❌ Type 'OrderId' is not assignable to type 'UserId'

Why the __brand property works

The intersection K & { __brand: T } tells TypeScript that the value must satisfy both the primitive shape and the phantom property. Since the property never exists at runtime, any value that is assignable to K can be cast to the branded type, but the compiler will only allow that cast explicitly.


2. Creating Safe Constructors

Direct casting (uid as UserId) defeats the purpose because any string could be turned into a UserId. Instead we expose factory functions that perform runtime validation (if needed) and return the branded type.

function makeUserId(value: string): UserId {
  // Optional runtime check – e.g., enforce a prefix
  if (!/^user-\d+$/.test(value)) {
    throw new Error(`Invalid UserId: ${value}`);
  }
  return value as UserId;
}

The same pattern applies to other brands:

function makeOrderId(value: string): OrderId {
  if (!/^order-\d+$/.test(value)) {
    throw new Error(`Invalid OrderId: ${value}`);
  }
  return value as OrderId;
}

Now the compiler forces us to go through the constructor:

const uid = makeUserId("user-42");
const oid = makeOrderId("order-99");

// getUser(uid); // ✅ OK
// getUser(oid); // ❌ Compile‑time error

When to skip runtime checks

If the source of the value is already trusted (e.g., a database column that guarantees the format), you can provide a unchecked constructor:

function unsafeUserId(value: string): UserId {
  return value as UserId; // use with caution!
}

Having both safe and unsafe variants gives you flexibility while keeping the type‑level guarantee.


3. Real‑World Use Cases

3.1 API Contracts

When consuming a REST endpoint that returns JSON, you often receive raw strings for IDs. Branded types let you keep the contract explicit throughout the client.

interface GetUserResponse {
  id: string; // raw JSON
  name: string;
}

// Convert once, then propagate the branded type
function parseUser(json: GetUserResponse) {
  return {
    id: makeUserId(json.id),
    name: json.name,
  };
}

All downstream functions now accept UserId instead of a generic string, preventing accidental misuse.

3.2 Domain Modeling

In a financial application, you might have several numeric concepts: Cents, Dollars, Percentage. Mixing them up can cause catastrophic bugs.

type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;
type Percentage = Brand<number, "Percentage">;

function centsToDollars(c: Cents): Dollars {
  return (c / 100) as Dollars;
}

Attempting to pass a Percentage where a Cents is expected triggers a compile‑time error, eliminating a whole class of unit‑conversion mistakes.

3.3 Interoperability with Third‑Party Libraries

Many libraries accept plain primitives (e.g., a logging library that expects a string tag). You can safely pass a branded value because it’s still assignable to the underlying primitive:

declare function log(tag: string, msg: string): void;

log(uid, "User fetched"); // ✅ OK – UserId is a string at runtime

Conversely, if a library returns a primitive that you want to treat as a brand, wrap the result with a factory before exposing it to the rest of your code.


4. Advanced Patterns

4.1 Symbol‑Based Brands

Using a symbol as the brand guarantees uniqueness without relying on string literals.

declare const UserIdBrand: unique symbol;
type UserId = Brand<string, typeof UserIdBrand>;

function makeUserId(value: string): UserId {
  // validation …
  return value as UserId;
}

unique symbol ensures that even if two files accidentally reuse the same string literal, the brands remain distinct.

4.2 Opaque Types with type vs interface

If you prefer a more “opaque” feel—preventing accidental structural compatibility—you can hide the brand behind an interface with a private field:

interface UserId {
  readonly __brand: unique symbol;
}
type UserId = string & UserId; // intersection

// The brand is not exported, so external code cannot construct it directly.

This pattern is useful for library authors who want to expose a nominal type without leaking the brand definition.

4.3 Utility Types for Bulk Branding

When you have many similar IDs, writing a brand for each can become repetitive. A generic helper can automate it:

type Branded<T, B extends string> = T & { readonly __brand: B };

type IdMap = {
  user: string;
  order: string;
  product: number;
};

type BrandedIds = {
  [K in keyof IdMap]: Branded<IdMap[K], Capitalize<K> + "Id">;
};

type UserId = BrandedIds["user"];   // string & { __brand: "UserId" }
type OrderId = BrandedIds["order"]; // string & { __brand: "OrderId" }
type ProductId = BrandedIds["product"]; // number & { __brand: "ProductId" }

Now you can maintain a single source of truth (IdMap) and generate all branded types automatically.


5. Tooling & IDE Experience

  • Hover information – VS Code shows the full expanded type (string & { __brand: "UserId" }), reminding you that the value is branded.
  • Auto‑import of factories – If you place factories in a ids.ts module, the IDE can suggest them when you try to assign a raw string to a branded variable.
  • Linting – ESLint rules such as no-unsafe-assignment can be configured to flag direct casts (as UserId) outside of designated factory files.

6. Common Pitfalls

Pitfall Symptom Fix
Accidental any propagation any values bypass branding checks. Keep any usage minimal; cast to branded types only after validation.
Over‑branding Too many brands make code noisy. Brand only concepts that can be confused (IDs, units, security tokens).
Missing runtime validation Invalid strings become branded, causing downstream errors. Provide a safe factory that validates; use unsafe* only in trusted contexts.
Brand leakage Exporting the __brand property allows external code to forge values. Keep the brand definition private (e.g., declare const UserIdBrand: unique symbol; inside a module).

7. Migration Strategy

  1. Identify candidates – Scan the codebase for primitive values that represent domain concepts (IDs, timestamps, monetary amounts).
  2. Add brand definitions – Create a types/brands.ts file and declare the needed brands.
  3. Introduce factories – Write a small wrapper around the places where the values are created (API responses, DB rows).
  4. Refactor signatures – Replace string/number parameters with the new branded types.
  5. Run the compiler – TypeScript will highlight all mismatched usages; fix them by either using factories or adjusting logic.
  6. Add tests – Ensure that factories reject malformed inputs (if you added runtime checks).

Because the branding is erased at compile time, there’s no performance impact, and the migration can be done incrementally.


8. Best Practices Checklist

  • Brand only where confusion is possible – IDs, units, security tokens, etc.
  • Prefer unique symbol brands for absolute uniqueness.
  • Expose a single factory per brand; keep the cast (as Brand) private.
  • Validate at the boundary (API, DB, user input) and then treat the value as safe.
  • Document the intent – a short comment next to the brand definition helps newcomers understand why it exists.
  • Avoid mixing branded and unbranded values in the same data structure unless you deliberately want flexibility.

9. Conclusion

Branded (opaque) types give TypeScript developers a lightweight, zero‑runtime‑overhead way to achieve nominal typing. By attaching a phantom property to a primitive, you gain compile‑time guarantees that prevent accidental misuse of semantically distinct values. The pattern integrates seamlessly with existing tooling, works well with third‑party libraries, and can be introduced gradually into any codebase.

Adopt branded types where the cost of a subtle bug outweighs the added verbosity, and you’ll find your TypeScript projects become more self‑documenting, safer, and easier to refactor.