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 stringRenaming 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'>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 stringCasting — 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