Skip to content
C Codeloom
TypeScript

TypeScript Branded Types Tutorial

Use branded types in TypeScript to add nominal safety on top of structural types. Stop mixing up UserIds, emails, and raw strings at compile time.

·5 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • What branded types are and why they exist
  • How to add nominal safety to structural types
  • Building constructors and smart parsers
  • Patterns for ids, emails, and validated values
  • When brands hurt more than they help

Prerequisites

  • Basic TypeScript
  • Familiarity with intersection types

What and Why

TypeScript uses structural typing: two types are compatible if their shapes match. That is usually a feature, but it bites when two strings represent very different things. A UserId and a PostId are both string, so the compiler happily lets you pass one where the other is expected. The same is true for a raw user input string and a validated email.

Branded types add a synthetic marker to a value’s type so the compiler treats otherwise identical shapes as distinct. At runtime, the value is still a plain string or number. At compile time, only constructors you control can produce the branded form, giving you the safety of nominal typing without giving up structural ergonomics elsewhere.

Mental Model

Think of a brand as a sticker. The underlying value is unchanged, but the sticker tells the type system: “this string has been blessed as a UserId.” The sticker is invisible at runtime, but it cannot be peeled off accidentally because TypeScript checks it on every assignment.

The trick is to intersect the base type with an object that carries an unforgeable tag. Because the tag uses a unique symbol or a property no one would type by hand, the only way to get a branded value is through a constructor that returns the branded type.

Hands-on Example

The minimal brand looks like this.

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

function asUserId(s: string): UserId {
  return s as UserId;
}

const u: UserId = asUserId('u_123');
const p: PostId = u; // Error: brand mismatch

The unique symbol ensures no one can construct that property literally. The constructor is a single typed cast, isolated in one place. Everywhere else, you receive and pass UserId values that cannot be confused with PostId or with a bare string.

A more interesting case is validation. A ValidEmail brand guarantees that any value of that type has actually passed the email check.

type ValidEmail = Brand<string, 'ValidEmail'>;

function parseEmail(input: string): ValidEmail | null {
  return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input)
    ? (input as ValidEmail)
    : null;
}

function send(to: ValidEmail) { /* ... */ }

const e = parseEmail(userInput);
if (e) send(e); // safe, narrowed to ValidEmail

The brand makes “validated” a compile-time property. Any function that takes a ValidEmail knows the input is real, so the check happens once at the boundary instead of being scattered defensively.

         string  (raw, unchecked)
          |
          +----> asUserId() ------> UserId
          |
          +----> asPostId() ------> PostId
          |
          +----> parseEmail() ----> ValidEmail | null

Compiler treats UserId, PostId, ValidEmail as
distinct types even though they all carry strings.
Brand isolates structurally identical types

You can compose brands. A NonEmpty<string> and a Trimmed<string> can be intersected to mean both. Order of branding does not matter as long as the constructor enforces every invariant before returning.

Common Pitfalls

The biggest mistake is leaking the cast. If you scatter as UserId calls through your codebase, the brand becomes decorative. Keep constructors in one module and forbid casts elsewhere with an ESLint rule.

Another trap is treating brands as runtime markers. They are not. The property does not exist on the value. Do not serialize it, do not log it, do not check for it with in. The brand lives only in the type checker.

A subtle issue is JSON deserialization. When you parse JSON, you receive plain strings, not branded ones. Use a parser like Zod or a hand-written validator at the boundary to brand values as they enter the system.

Brands can also interact awkwardly with libraries that expect bare string. Most accept the branded type because of structural compatibility, but some generic helpers infer the wrong type. When that happens, narrow with as string at the call site, not earlier.

Best Practices

Put each brand and its constructor in one small module. Export the type and the constructor; never export a way to bypass the constructor. This single point of trust is what makes brands safe.

Name brands for invariants, not implementations. NonEmptyString and ValidEmail describe guarantees. UserId and PostId describe domains. Both are useful; mixed metaphors are not.

Pair brands with parsers that return Result or nullable types. This forces callers to handle invalid inputs explicitly instead of throwing deep in business logic.

Document brands in code comments or README. Newcomers see UserId and wonder why they cannot just pass a string. A one-line note pointing to the constructor saves hours.

Wrap-up

Branded types give you nominal safety inside a structural type system. They turn dangerous primitives like ids and emails into self-documenting, hard-to-misuse values without runtime cost. The pattern is small, the payoff is large, and once a codebase adopts brands at its boundaries, whole classes of bugs simply stop happening.

Add brands where shape collisions cost the most: ids, validated inputs, currency, and units. Skip them where the friction outweighs the safety. Used judiciously, brands are one of TypeScript’s best-kept secrets.