TypeScript Brand Type: Type-Safe IDs and Branded Types in Practice
TypeScript Brand Type: Type-Safe IDs and Branded Types in Practice
TypeScript uses structural typing — if the shape matches, the types are compatible. This means UserId and OrderId can be assigned to each other when both are string, and the compiler won't complain. In business code, this is a disaster: passing a user ID to an order query interface, only to discover the error at runtime.
Brand Type is the classic pattern for implementing Nominal Typing in TypeScript. It "brands" a type with a unique marker, making structurally identical types incompatible at compile time. This article provides a complete Brand Type practical guide, from basics to production-grade.
Core Concepts at a Glance
| Concept | Structural Typing | Brand Type (Nominal) | Difference |
|---|---|---|---|
| Type Compatibility | Same structure = compatible | Even same structure = incompatible | ⭐⭐⭐⭐⭐ |
| Compile-Time Checking | Weak (ID misuse not caught) | Strong (ID misuse caught immediately) | ⭐⭐⭐⭐⭐ |
| Runtime Overhead | None | None (type erasure) | ⭐ |
| Code Readability | Weak (all strings) | Strong (clear semantics) | ⭐⭐⭐⭐ |
| Refactoring Safety | Low | High (type changes propagate) | ⭐⭐⭐⭐⭐ |
| Learning Curve | Low | Medium | ⭐⭐⭐ |
Five Pain Points Analysis
Pain Point 1: Primitive ID Type Mixing Cannot Be Detected
// Disaster under structural typing
function getUser(id: string) { /* ... */ }
function getOrder(id: string) { /* ... */ }
const userId = 'user_123'
const orderId = 'order_456'
getUser(orderId) // Compiles! Runtime error discovered too late
getOrder(userId) // Compiles! Runtime error discovered too late
Pain Point 2: Unit Types Cannot Be Distinguished at Compile Time
// Amount unit mixing
function processPayment(amount: number, currency: string) { /* ... */ }
processPayment(100, 'CNY') // Is 100 in fen or yuan?
processPayment(100, 'USD') // Is 100 in cents or dollars?
Pain Point 3: State Transitions Cannot Be Constrained at the Type Level
// State machine without type constraints
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'completed'
function transition(current: OrderStatus, next: OrderStatus) { /* ... */ }
transition('completed', 'pending') // Compiles! But business rules disallow this
Pain Point 4: API Response Types Lack Domain Semantics
// API responses use primitive types, lacking business semantics
interface ApiResponse {
id: string // What ID?
parentId: string // Parent of what ID?
refId: string // Reference to what ID?
}
Pain Point 5: Cross-Module Type Passing Lacks Safety Guarantees
// Type information lost during cross-module passing
function parseInput(raw: string): number { return Number(raw) }
function calculateArea(width: number, height: number): number { /* ... */ }
const userId = parseInput('123') // Returns number, but semantically it's a UserId
calculateArea(userId, 100) // Compiles! But semantically completely wrong
Five Core Patterns in Practice
Pattern 1: Brand Type Basic Definition
Runtime Environment: TypeScript 5.5+, Node.js 20+
// brand.ts - Brand Type core definition
/**
* Brand Type utility
* Adds a unique brand marker to the underlying type via intersection
* Completely erased at runtime, zero overhead
*/
// ===== 1. Basic Brand Definition =====
/**
* Generic utility for creating branded types
* @template T - Underlying primitive type
* @template Brand - Brand marker (unique identifier)
*/
type Brand<T, Brand extends string> = T & { readonly __brand: Brand }
// Concrete brand type definitions
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
type ProductId = Brand<string, 'ProductId'>
// Numeric brands
type Milliseconds = Brand<number, 'Milliseconds'>
type Seconds = Brand<number, 'Seconds'>
type Pixels = Brand<number, 'Pixels'>
// ===== 2. Brand Constructors =====
/**
* Type-safe brand value constructors
* Ensure brand values can only be created through constructors
*/
function createUserId(value: string): UserId {
return value as UserId
}
function createOrderId(value: string): OrderId {
return value as OrderId
}
function createMilliseconds(value: number): Milliseconds {
return value as Milliseconds
}
// ===== 3. Compile-Time Type Safety Verification =====
const userId = createUserId('user_123')
const orderId = createOrderId('order_456')
// ✅ Correct: types match
function getUser(id: UserId): void { console.log('User:', id) }
getUser(userId) // OK
// ❌ Compile error: types don't match
// getUser(orderId) // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
// ❌ Compile error: primitive type cannot be directly assigned
// const uid: UserId = 'raw_string' // Error: Type 'string' is not assignable to type 'UserId'
// brand-advanced.ts - Advanced Brand Type patterns
/**
* Immutable Brand: prevents brand values from being modified
*/
type ImmutableBrand<T, Brand extends string> = Readonly<T> & {
readonly __brand: Brand
readonly __immutable: true
}
/**
* Validated Brand: performs runtime validation at construction time
*/
type ValidatedBrand<T, Brand extends string> = T & {
readonly __brand: Brand
readonly __validated: true
}
/**
* Create a brand constructor with validation
*/
function createValidatedBrand<T, Brand extends string>(
brand: Brand,
validate: (value: T) => boolean,
errorMessage: string
): (value: T) => ValidatedBrand<T, Brand> {
return (value: T): ValidatedBrand<T, Brand> => {
if (!validate(value)) {
throw new TypeError(`Validation failed for ${brand}: ${errorMessage}`)
}
return value as ValidatedBrand<T, Brand>
}
}
// Example: Email brand type
type Email = ValidatedBrand<string, 'Email'>
const createEmail = createValidatedBrand(
'Email',
(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
'Invalid email format'
)
const email = createEmail('zhang@toolsku.com') // OK
// createEmail('not-an-email') // TypeError: Validation failed for Email: Invalid email format
// Example: Positive integer brand type
type PositiveInt = ValidatedBrand<number, 'PositiveInt'>
const createPositiveInt = createValidatedBrand(
'PositiveInt',
(v: number) => Number.isInteger(v) && v > 0,
'Value must be a positive integer'
)
const count = createPositiveInt(42) // OK
// createPositiveInt(-1) // TypeError
// createPositiveInt(3.14) // TypeError
Pattern 2: Type-Safe IDs
// branded-ids.ts - Production-grade type-safe ID system
/**
* ID brand type system
* Solves: different entity ID mixing, ID format validation, ID serialization/deserialization
*/
// ===== 1. ID Brand Definitions =====
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
type ProductId = Brand<string, 'ProductId'>
type TenantId = Brand<string, 'TenantId'>
type SessionId = Brand<string, 'SessionId'>
// UUID format ID brands
type UuidBrand<T extends string> = ValidatedBrand<string, T>
type UserUuid = UuidBrand<'UserUuid'>
type OrderUuid = UuidBrand<'OrderUuid'>
// ===== 2. ID Constructor Factory =====
interface IdConfig {
prefix: string
separator?: string
validate?: (value: string) => boolean
}
function createIdFactory<T extends string>(config: IdConfig) {
const separator = config.separator ?? '_'
const validate = config.validate ?? ((v: string) => v.startsWith(config.prefix + separator))
function create(raw: string): Brand<string, T> {
if (!validate(raw)) {
throw new TypeError(
`Invalid ID format for ${config.prefix}: expected "${config.prefix}${separator}...", got "${raw}"`
)
}
return raw as Brand<string, T>
}
function generate(): Brand<string, T> {
const id = `${config.prefix}${separator}${crypto.randomUUID().slice(0, 8)}`
return id as Brand<string, T>
}
function isType(value: string): value is Brand<string, T> {
return validate(value)
}
return { create, generate, isType }
}
// ===== 3. Concrete ID Factories =====
const UserIdFactory = createIdFactory<'UserId'>({
prefix: 'usr',
validate: (v) => /^usr_[a-zA-Z0-9]{8,}$/.test(v),
})
const OrderIdFactory = createIdFactory<'OrderId'>({
prefix: 'ord',
validate: (v) => /^ord_[a-zA-Z0-9]{8,}$/.test(v),
})
const ProductIdFactory = createIdFactory<'ProductId'>({
prefix: 'prd',
validate: (v) => /^prd_[a-zA-Z0-9]{8,}$/.test(v),
})
// ===== 4. Usage Examples =====
// Generate new IDs
const newUserId = UserIdFactory.generate() // "usr_a1b2c3d4"
const newOrderId = OrderIdFactory.generate() // "ord_e5f6g7h8"
// Create from string (with validation)
const existingUserId = UserIdFactory.create('usr_existing1')
// UserIdFactory.create('ord_wrong_prefix') // TypeError
// Type guard
function processId(raw: string) {
if (UserIdFactory.isType(raw)) {
// raw is narrowed to UserId
getUser(raw) // OK
}
}
// ===== 5. IDs in API Types =====
interface UserDTO {
id: UserId
name: string
email: Email
tenantId: TenantId
}
interface OrderDTO {
id: OrderId
userId: UserId // Foreign key: type-safe
productId: ProductId // Foreign key: type-safe
amount: CNY // Currency brand (see Pattern 3)
createdAt: ISODateString // Time brand
}
// Compile-time prevention of ID mixing
function getUser(id: UserId): UserDTO { /* ... */ }
function getOrder(id: OrderId): OrderDTO { /* ... */ }
// getUser(newOrderId) // ❌ Compile error
// getOrder(newUserId) // ❌ Compile error
getUser(newUserId) // ✅ Correct
getOrder(newOrderId) // ✅ Correct
// branded-id-serialization.ts - ID serialization and deserialization
/**
* Brand ID serialization/deserialization
* Handle API boundaries: JSON → Brand Type → JSON
*/
// Serialization: Brand Type → Primitive (automatic, type erasure)
function serializeUser(user: UserDTO): object {
return {
id: user.id as string, // Brand → string
name: user.name,
email: user.email as string, // Brand → string
tenantId: user.tenantId as string,
}
}
// Deserialization: Primitive → Brand Type (requires validation)
function deserializeUser(raw: unknown): UserDTO {
if (typeof raw !== 'object' || raw === null) {
throw new TypeError('Invalid user data')
}
const data = raw as Record<string, unknown>
return {
id: UserIdFactory.create(data.id as string),
name: data.name as string,
email: createEmail(data.email as string),
tenantId: data.tenantId as TenantId,
}
}
// Zod integration: runtime validation + compile-time type safety
import { z } from 'zod'
const UserIdSchema = z.string().regex(/^usr_[a-zA-Z0-9]{8,}$/).transform((v) => UserIdFactory.create(v))
const OrderIdSchema = z.string().regex(/^ord_[a-zA-Z0-9]{8,}$/).transform((v) => OrderIdFactory.create(v))
const EmailSchema = z.string().email().transform((v) => createEmail(v))
const UserDTOSchema = z.object({
id: UserIdSchema,
name: z.string().min(1),
email: EmailSchema,
tenantId: z.string().transform((v) => v as TenantId),
})
// API response parsing
function parseUserResponse(data: unknown): UserDTO {
return UserDTOSchema.parse(data) // Runtime validation + brand type
}
Pattern 3: Currency Unit Types
// branded-currency.ts - Type-safe currency system
/**
* Currency brand types
* Solves: amount unit mixing (fen/yuan), currency mixing, precision loss
*/
// ===== 1. Currency Brand Definitions =====
type CNY = Brand<number, 'CNY'> // Chinese Yuan (fen)
type USD = Brand<number, 'USD'> // US Dollar (cents)
type JPY = Brand<number, 'JPY'> // Japanese Yen (whole yen, no decimals)
// Amount brand with precision
type CNY_Yuan = Brand<number, 'CNY_Yuan'> // Chinese Yuan (yuan)
// ===== 2. Currency Constructors =====
function createCNY(fen: number): CNY {
if (!Number.isInteger(fen)) {
throw new TypeError('CNY amount must be in fen (integer)')
}
if (fen < 0) {
throw new TypeError('CNY amount must be non-negative')
}
return fen as CNY
}
function createUSD(cents: number): USD {
if (!Number.isInteger(cents)) {
throw new TypeError('USD amount must be in cents (integer)')
}
if (cents < 0) {
throw new TypeError('USD amount must be non-negative')
}
return cents as USD
}
function createJPY(yen: number): JPY {
if (!Number.isInteger(yen)) {
throw new TypeError('JPY amount must be in yen (integer)')
}
if (yen < 0) {
throw new TypeError('JPY amount must be non-negative')
}
return yen as JPY
}
// ===== 3. Type-Safe Currency Operations =====
function addCNY(a: CNY, b: CNY): CNY {
return createCNY((a as number) + (b as number))
}
function subtractCNY(a: CNY, b: CNY): CNY {
const result = (a as number) - (b as number)
if (result < 0) {
throw new RangeError('CNY subtraction result cannot be negative')
}
return createCNY(result)
}
function multiplyCNY(amount: CNY, factor: number): CNY {
return createCNY(Math.round((amount as number) * factor))
}
// ❌ Compile error: different currencies cannot be directly operated on
// function addCNY_USD(a: CNY, b: USD): CNY { ... } // Type mismatch
// ===== 4. Currency Conversion (requires exchange rate) =====
interface ExchangeRate {
from: string
to: string
rate: number
updatedAt: Date
}
function convertCNYtoUSD(cny: CNY, rate: ExchangeRate): USD {
if (rate.from !== 'CNY' || rate.to !== 'USD') {
throw new TypeError('Invalid exchange rate direction')
}
const usdCents = Math.round((cny as number) * rate.rate / 100)
return createUSD(usdCents)
}
// ===== 5. Formatting Output =====
function formatCNY(amount: CNY): string {
const yuan = (amount as number) / 100
return `¥${yuan.toFixed(2)}`
}
function formatUSD(amount: USD): string {
const dollars = (amount as number) / 100
return `$${dollars.toFixed(2)}`
}
function formatJPY(amount: JPY): string {
return `¥${amount as number}`
}
// ===== 6. Usage Example =====
const price = createCNY(9900) // 99.00 yuan
const discount = createCNY(1000) // 10.00 yuan
const finalPrice = subtractCNY(price, discount) // 89.00 yuan
console.log(formatCNY(finalPrice)) // ¥89.00
// const wrongPrice = createUSD(9900) // USD type
// addCNY(price, wrongPrice) // ❌ Compile error: USD not assignable to CNY parameter
Pattern 4: State Machine Types
// branded-state-machine.ts - Type-safe state machine
/**
* State machine brand types
* Solves: illegal state transitions, state omissions, unconstrained transition side effects
*/
// ===== 1. Brand State Definitions =====
type PendingOrder = Brand<'pending', 'PendingOrder'>
type PaidOrder = Brand<'paid', 'PaidOrder'>
type ShippedOrder = Brand<'shipped', 'ShippedOrder'>
type CompletedOrder = Brand<'completed', 'CompletedOrder'>
type CancelledOrder = Brand<'cancelled', 'CancelledOrder'>
// ===== 2. Type-Safe State Transitions =====
// Only allow legal state transitions
function payOrder(_order: PendingOrder): PaidOrder {
return 'paid' as PaidOrder
}
function shipOrder(_order: PaidOrder): ShippedOrder {
return 'shipped' as ShippedOrder
}
function completeOrder(_order: ShippedOrder): CompletedOrder {
return 'completed' as CompletedOrder
}
function cancelPendingOrder(_order: PendingOrder): CancelledOrder {
return 'cancelled' as CancelledOrder
}
function cancelPaidOrder(_order: PaidOrder): CancelledOrder {
return 'cancelled' as CancelledOrder
}
// ❌ Compile error: illegal transitions
// function unshipOrder(_order: CompletedOrder): ShippedOrder { ... } // This function doesn't exist
// function repayOrder(_order: ShippedOrder): PaidOrder { ... } // This function doesn't exist
// ===== 3. Data-Carrying Brand States =====
interface OrderStateData<S extends string> {
status: Brand<S, `Order_${S}`>
updatedAt: Date
updatedBy: UserId
}
type PendingOrderState = OrderStateData<'pending'> & {
items: Array<{ productId: ProductId; quantity: PositiveInt }>
}
type PaidOrderState = OrderStateData<'paid'> & {
items: Array<{ productId: ProductId; quantity: PositiveInt }>
paidAt: Date
paymentMethod: 'alipay' | 'wechat' | 'card'
transactionId: string
}
type ShippedOrderState = OrderStateData<'shipped'> & {
items: Array<{ productId: ProductId; quantity: PositiveInt }>
paidAt: Date
shippedAt: Date
trackingNumber: string
carrier: string
}
// ===== 4. State Transition Functions (with data) =====
function payOrderWithData(
order: PendingOrderState,
paymentMethod: 'alipay' | 'wechat' | 'card',
transactionId: string
): PaidOrderState {
return {
status: 'paid' as Brand<'paid', 'Order_paid'>,
updatedAt: new Date(),
updatedBy: order.updatedBy,
items: order.items,
paidAt: new Date(),
paymentMethod,
transactionId,
}
}
function shipOrderWithData(
order: PaidOrderState,
trackingNumber: string,
carrier: string
): ShippedOrderState {
return {
status: 'shipped' as Brand<'shipped', 'Order_shipped'>,
updatedAt: new Date(),
updatedBy: order.updatedBy,
items: order.items,
paidAt: order.paidAt,
shippedAt: new Date(),
trackingNumber,
carrier,
}
}
// ===== 5. Exhaustive Check =====
type AnyOrderState = PendingOrderState | PaidOrderState | ShippedOrderState
function handleOrderState(state: AnyOrderState): string {
switch (state.status as string) {
case 'pending':
return 'Awaiting payment'
case 'paid':
return 'Paid, awaiting shipment'
case 'shipped':
return 'Shipped, awaiting delivery confirmation'
// If a state is missing, TypeScript will error at compile time
default:
const _exhaustive: never = state.status
throw new Error(`Unhandled state: ${_exhaustive}`)
}
}
Pattern 5: Production-Grade Brand Type Utility Library
// brand-toolkit.ts - Production-grade Brand Type utility library
/**
* Complete Brand Type toolkit
* Covers: creation, validation, serialization, composition, mapping
*/
// ===== 1. Core Utility Types =====
/** Base brand type */
type Brand<T, B extends string> = T & { readonly __brand: B }
/** Extract brand marker */
type ExtractBrand<B> = B extends Brand<infer T, infer _>
? _ extends string ? _ : never
: never
/** Extract underlying type */
type Unbrand<B> = B extends Brand<infer T, infer _> ? T : B
/** Check if branded type */
type IsBranded<T> = T extends Brand<any, any> ? true : false
// ===== 2. Brand Creation Tool =====
interface BrandCreatorConfig<T, B extends string> {
name: B
validate?: (value: T) => boolean
normalize?: (value: T) => T
errorMessage?: string
}
function createBrandCreator<T, B extends string>(
config: BrandCreatorConfig<T, B>
) {
const { name, validate, normalize, errorMessage } = config
function create(value: T): Brand<T, B> {
const normalized = normalize ? normalize(value) : value
if (validate && !validate(normalized)) {
throw new TypeError(
errorMessage ?? `Invalid value for brand "${name}": ${String(normalized)}`
)
}
return normalized as Brand<T, B>
}
function isType(value: T): value is Brand<T, B> {
return validate ? validate(value) : true
}
function unsafeCast(value: T): Brand<T, B> {
return value as Brand<T, B>
}
function unwrap(branded: Brand<T, B>): T {
return branded as T
}
return { create, isType, unsafeCast, unwrap, name }
}
// ===== 3. Common Brand Creators =====
// Phone number brand
const PhoneNumber = createBrandCreator<string, 'PhoneNumber'>({
name: 'PhoneNumber',
validate: (v) => /^1[3-9]\d{9}$/.test(v),
normalize: (v) => v.replace(/\s/g, ''),
errorMessage: 'Invalid Chinese phone number format',
})
// URL brand
const SecureUrl = createBrandCreator<string, 'SecureUrl'>({
name: 'SecureUrl',
validate: (v) => /^https:\/\//.test(v),
errorMessage: 'URL must use HTTPS protocol',
})
// Non-empty string brand
const NonEmptyString = createBrandCreator<string, 'NonEmptyString'>({
name: 'NonEmptyString',
validate: (v) => v.trim().length > 0,
normalize: (v) => v.trim(),
errorMessage: 'String must not be empty',
})
// Positive integer brand
const PositiveInteger = createBrandCreator<number, 'PositiveInteger'>({
name: 'PositiveInteger',
validate: (v) => Number.isInteger(v) && v > 0,
errorMessage: 'Value must be a positive integer',
})
// ===== 4. Brand Composition =====
type BrandedEmail = Brand<string, 'Email'> & Brand<string, 'NonEmpty'>
const BrandedEmailCreator = {
create(value: string): BrandedEmail {
const trimmed = value.trim()
if (trimmed.length === 0) {
throw new TypeError('Email must not be empty')
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
throw new TypeError('Invalid email format')
}
return trimmed as BrandedEmail
},
unwrap(value: BrandedEmail): string {
return value as string
},
}
// ===== 5. Brand Mapping Tool =====
function mapBranded<T, B extends string, R>(
branded: Brand<T, B>,
mapper: (value: T) => R
): R {
return mapper(branded as T)
}
function mapBrandedTo<T, B extends string, R, RB extends string>(
branded: Brand<T, B>,
mapper: (value: T) => R,
targetBrand: RB
): Brand<R, RB> {
return mapper(branded as T) as Brand<R, RB>
}
// Usage example
const userId = UserIdFactory.create('usr_abc12345')
const userIdLength = mapBranded(userId, (id) => id.length) // number
// ===== 6. Brand Type Guard Collection =====
function createTypeGuard<T, B extends string>(
validate: (value: T) => boolean
): (value: T) => value is Brand<T, B> {
return (value: T): value is Brand<T, B> => validate(value)
}
const isUserId = createTypeGuard<string, 'UserId'>(
(v) => /^usr_[a-zA-Z0-9]{8,}$/.test(v)
)
const isOrderId = createTypeGuard<string, 'OrderId'>(
(v) => /^ord_[a-zA-Z0-9]{8,}$/.test(v)
)
// Usage
function processUnknownId(raw: string) {
if (isUserId(raw)) {
getUser(raw) // raw: UserId
} else if (isOrderId(raw)) {
getOrder(raw) // raw: OrderId
}
}
// ===== 7. Brand Type Registry =====
class BrandRegistry {
private creators = new Map<string, ReturnType<typeof createBrandCreator<any, any>>>()
register<T, B extends string>(name: B, creator: ReturnType<typeof createBrandCreator<T, B>>): void {
this.creators.set(name, creator)
}
get<B extends string>(name: B): ReturnType<typeof createBrandCreator<any, B>> | undefined {
return this.creators.get(name)
}
validate(name: string, value: unknown): boolean {
const creator = this.creators.get(name)
if (!creator) return false
try {
creator.create(value)
return true
} catch {
return false
}
}
}
// Global registration
const brandRegistry = new BrandRegistry()
brandRegistry.register('PhoneNumber', PhoneNumber)
brandRegistry.register('SecureUrl', SecureUrl)
brandRegistry.register('NonEmptyString', NonEmptyString)
brandRegistry.register('PositiveInteger', PositiveInteger)
Five Pitfall Avoidance Guide
Pitfall 1: Brand Type Assertion Abuse
// ❌ Wrong: Using as assertions everywhere to bypass type checking
const userId = 'raw_string' as UserId // Bypasses validation
// ✅ Correct: Only create brand values through constructors
const userId = UserIdFactory.create('usr_abc12345')
Pitfall 2: Brand Type Conflicts with Generics
// ❌ Wrong: Generic parameter loses brand information
function processValue<T extends string>(value: T): string { return value }
processValue(userId) // Returns string, loses UserId brand
// ✅ Correct: Preserve brand type passing
function processBranded<T extends Brand<string, any>>(value: T): Unbrand<T> {
return value as Unbrand<T>
}
Pitfall 3: Brand Type Behavior in Conditional Types
// ❌ Wrong: Brand information may be lost in conditional types
type UnwrapIfBranded<T> = T extends Brand<infer U, any> ? U : T
type Result = UnwrapIfBranded<UserId> // string, brand info lost
// ✅ Correct: If you need to preserve the brand, don't unwrap
type KeepBrand<T> = T // Keep as-is
Pitfall 4: Brand Types and JSON Serialization
// ❌ Wrong: Brand information lost after JSON.parse
const json = JSON.stringify(user)
const parsed = JSON.parse(json) as UserDTO // Unsafe! No runtime validation
// ✅ Correct: Use Zod or custom parser for validation
const parsed = UserDTOSchema.parse(JSON.parse(json)) // Runtime validation + brand type
Pitfall 5: Brand Type as const Conflicts with Literals
// ❌ Wrong: as const makes the brand marker a literal type
const status = 'pending' as const as PendingOrder
// ✅ Correct: Brand constructor handles assertion internally
const status = createPendingOrder() // Function return type is explicit
Error Troubleshooting Table
| Error Message | Cause | Solution |
|---|---|---|
Type 'string' is not assignable to type 'UserId' |
Raw string cannot be directly assigned to brand type | Use brand constructor to create values |
Argument of type 'OrderId' is not assignable to parameter of type 'UserId' |
Different brand types are incompatible | Check if wrong ID type was passed |
Type 'Brand<string, "UserId">' is not assignable to type 'string' |
Brand type cannot be directly assigned to primitive | Use unwrap or as string to unbox |
Conversion of type 'string' to type 'UserId' may be a mistake |
Direct as assertion may be unsafe | Use brand constructor instead of direct assertion |
Property '__brand' does not exist on type 'string' |
Accessing brand marker at runtime | Brand marker only exists at compile time, erased at runtime |
Type instantiation is excessively deep |
Brand type nesting too deep | Simplify brand composition levels, split into independent brands |
Cannot find module 'zod' |
Zod not installed | npm install zod |
This expression is not callable |
Brand constructor not correctly exported | Check module export and import paths |
Type 'never' is not assignable |
Missing state in exhaustive check | Add missing state branch |
Generic type 'Brand' requires 2 type argument(s) |
Brand missing brand marker parameter | Add brand marker string, e.g. Brand<string, 'MyBrand'> |
Five Advanced Optimization Techniques
Technique 1: Template Literal Brands
// Template literal types + Brand: auto-generate entity ID brands
type EntityId<Entity extends string> = Brand<string, `${Entity}Id`>
type UserId = EntityId<'User'> // Brand<string, 'UserId'>
type OrderId = EntityId<'Order'> // Brand<string, 'OrderId'>
type ProductId = EntityId<'Product'> // Brand<string, 'ProductId'>
// Generic ID factory
function createEntityIdFactory<Entity extends string>(entity: Entity) {
const prefix = entity.toLowerCase().slice(0, 3)
return createIdFactory<`${Entity}Id`>({
prefix,
validate: (v) => new RegExp(`^${prefix}_[a-zA-Z0-9]{8,}$`).test(v),
})
}
Technique 2: Brand Types with Effect-TS Integration
// Use Effect's Brand module (safer)
import { Brand } from 'effect'
// Effect's Brand provides more complete runtime validation
const UserId = Brand.struct({
__brand: Brand.literal('UserId'),
value: Brand.string.pipe(Brand.pattern(/^usr_[a-zA-Z0-9]{8,}$/)),
})
type UserId = Brand.Brand.Construct<typeof UserId>
Technique 3: Brand Types with Prisma Integration
// Prisma models using brand types
import { Prisma } from '@prisma/client'
// Prisma-generated types are primitive, add brands via utility types
type BrandedUser = {
id: UserId
name: string
email: Email
createdAt: ISODateString
}
// Prisma result → Brand type
function toBrandedUser(prismaUser: Prisma.UserGetPayload<{}>): BrandedUser {
return {
id: UserIdFactory.create(prismaUser.id),
name: prismaUser.name,
email: createEmail(prismaUser.email),
createdAt: prismaUser.createdAt as ISODateString,
}
}
Technique 4: Brand Types with tRPC Integration
// tRPC procedures using brand types
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: UserIdSchema }))
.query(async ({ input }): Promise<UserDTO> => {
// input.id is already UserId brand type
return getUserById(input.id)
}),
createOrder: t.procedure
.input(z.object({
userId: UserIdSchema,
productId: ProductIdSchema,
amount: z.number().int().positive().transform((v) => createCNY(v)),
}))
.mutation(async ({ input }): Promise<OrderDTO> => {
// All IDs and amounts are brand types
return createOrder(input.userId, input.productId, input.amount)
}),
})
Technique 5: Brand Type Performance Monitoring
// brand-perf.ts - Brand type runtime validation performance monitoring
interface BrandValidationMetrics {
brandName: string
totalValidations: number
failedValidations: number
averageValidationTime: number // μs
}
class BrandPerfMonitor {
private metrics = new Map<string, {
total: number
failed: number
times: number[]
}>()
recordValidation(brandName: string, duration: number, success: boolean): void {
const metric = this.metrics.get(brandName) ?? { total: 0, failed: 0, times: [] }
metric.total++
if (!success) metric.failed++
metric.times.push(duration)
this.metrics.set(brandName, metric)
}
getMetrics(): BrandValidationMetrics[] {
return Array.from(this.metrics.entries()).map(([name, metric]) => ({
brandName: name,
totalValidations: metric.total,
failedValidations: metric.failed,
averageValidationTime:
metric.times.reduce((a, b) => a + b, 0) / metric.times.length,
}))
}
}
// Wrap brand constructor to add monitoring
function withPerfMonitor<T, B extends string>(
creator: ReturnType<typeof createBrandCreator<T, B>>,
monitor: BrandPerfMonitor
): typeof creator {
const originalCreate = creator.create.bind(creator)
return {
...creator,
create(value: T): Brand<T, B> {
const start = performance.now()
try {
const result = originalCreate(value)
const duration = (performance.now() - start) * 1000 // μs
monitor.recordValidation(creator.name, duration, true)
return result
} catch (error) {
const duration = (performance.now() - start) * 1000
monitor.recordValidation(creator.name, duration, false)
throw error
}
},
}
}
Comparison Analysis Table
| Dimension | Primitive Types | Brand Type | Zod Brand | io-ts | Effect Brand |
|---|---|---|---|---|---|
| Compile-Time Safety | ❌ | ✅ | ✅ | ✅ | ✅ |
| Runtime Validation | ❌ | Manual | ✅ Built-in | ✅ Built-in | ✅ Built-in |
| Runtime Overhead | None | None | Yes | Yes | Yes |
| Learning Curve | Low | Medium | Medium | High | Medium |
| Ecosystem Integration | Complete | Needs adaptation | tRPC-friendly | FP ecosystem | Effect ecosystem |
| Serialization | Automatic | Needs handling | Automatic | Automatic | Automatic |
| Type Inference | Automatic | Needs annotation | Automatic | Complex | Automatic |
| Bundle Size | 0 | 0 | ~13KB | ~8KB | ~50KB |
Summary
TypeScript Brand Type is an essential pattern for type-safe programming in 2026. Key takeaways:
- Basic Definition: Implement nominal typing through intersection type
T & { __brand: B }, zero runtime overhead - Type-Safe IDs: ID factory pattern for unified creation, validation, and type guards, eliminating ID mixing
- Currency Units: Brand types constrain currency and units, preventing direct operations across different currencies at compile time
- State Machines: Brand states limit legal transition paths, exhaustive checks ensure no omissions
- Production-Grade Toolkit: Brand creators, registries, Zod integration, performance monitoring, covering the complete lifecycle
Brand Type evolves TypeScript from "structural compatibility is enough" to "wrong semantics means no." In critical domains like IDs, amounts, and states, brand types are the first line of defense against runtime bugs.
Recommended Online Tools
- /en/json/format - JSON formatter, debug brand type serialization output
- /en/dev/curl-to-code - API request to code, brand type interface debugging
- /en/encode/hash - Hash calculator, brand ID uniqueness verification
- /en/text/diff - Text diff, compare brand type definition changes
Try these browser-local tools — no sign-up required →