Core

Concepts

How branded types work in TypeScript — and why they give you compile-time ID safety with zero runtime cost.

The structural typing problem

TypeScript uses structural typing: two types are compatible if they have the same shape. A string is always compatible with another string, no matter what you call it:

type UserId  = string
type OrderId = string

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

const orderId: OrderId = 'ord_abc123'
getUser(orderId) // ✓ no error — both are just string

Renaming a type alias to UserId doesn't create a new type — it's still string under the hood. TypeScript happily accepts any string in its place.

Branded types (nominal typing)

The trick is to attach a unique, invisible property to the type — one that can never exist at runtime. TypeScript uses this property to distinguish the types structurally, even though at runtime the value is still a plain string.

// The raw Brand utility type (what safeids uses internally)
declare const __brand: unique symbol

type Brand<T, TBrand extends string> =
  T & { readonly [__brand]: TBrand }

// Now UserId and OrderId are structurally different types
type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
Key insight: unique symbol creates a type-level symbol that can never be re-declared. The [__brand] property never exists on the actual runtime object — it only exists in the type system.

Why this works

After branding, TypeScript sees UserId and OrderId as structurally different — because they have different [__brand] values. Even though both carry a plain string at runtime:

import { brand, createId } from 'safeids'

type UserId  = brand<string, 'UserId'>
type OrderId = brand<string, 'OrderId'>

const userId:  UserId  = createId<UserId>('usr')  // → "usr_k2p9xm"
const orderId: OrderId = createId<OrderId>('ord') // → "ord_f7qrn1"

// At runtime, both values are plain strings.
// TypeScript treats them as incompatible types:
function getUser(id: UserId) {}

getUser(userId)   // ✓
getUser(orderId)  // ✗ Error: Argument of type 'OrderId' is not
                  //   assignable to parameter of type 'UserId'

Zero runtime overhead

The brand is a TypeScript-only construct. After compilation, UserId and OrderId are just string. There is no runtime wrapper, no object, no extra memory — just a plain string value with compile-time type safety layered on top.

// TypeScript source
const userId: UserId = createId<UserId>('usr')

// Compiled JavaScript output
const userId = createId('usr')
// → exactly "usr_k2p9xm" — a plain string

Casting — getting values in and out

Because brands don't exist at runtime, you can't create a branded value from a plain string without an explicit cast. safeids provides two helpers for this:

import { fromString, isId } from 'safeids'
import type { UserId } from '@/types/ids'

// fromString: cast a validated string to a branded type
// Throws if the string is empty or prefix doesn't match
const id = fromString<UserId>('usr_abc123', 'usr')

// isId: type guard — returns true if value looks like the ID
if (isId<UserId>(maybeId, 'usr')) {
  // maybeId is now narrowed to UserId here
  await getUser(maybeId)
}

Widening back to string

A branded ID is a subtype of string, so you can always widen it back to a plain string — for example when passing to a third-party library or JSON serialization:

const userId: UserId = createId<UserId>('usr')

// UserId extends string, so this is always safe
const raw: string = userId

JSON.stringify({ id: userId }) // works — it's just a string