Back to Blog

TypeScript Branded Types: Nominal Typing, Type-Safe IDs

Implement TypeScript branded types for nominal typing: branded primitives for type-safe IDs, currency amounts, and validated strings. Newtype pattern, runtime validation with Zod, and practical production patterns.

Viprasol Tech Team
13 min read
Updated 2026

TypeScript's type system is structural: if two types have the same shape, they're interchangeable. That means string typed as UserId and string typed as ProductId are the same type — the compiler won't stop you from passing a user ID where a product ID is expected. This is the root cause of an entire class of bugs: swapped ID arguments, dollars confused with cents, unvalidated strings treated as safe.

Branded types (also called nominal types or opaque types) add a "brand" — a phantom property — to a primitive type so the compiler can distinguish them. This post covers the pattern, practical applications, Zod integration for runtime branding, and performance considerations.

The Problem Without Branded Types

// ❌ All these are just `string` at the type level
type UserId = string;
type ProductId = string;
type OrderId = string;

async function getOrdersForUser(userId: UserId, productId: ProductId): Promise<Order[]> {
  return db.order.findMany({ where: { userId, productId } });
}

const userId = 'user-123';
const productId = 'prod-456';

// TypeScript accepts this! Arguments swapped — runtime bug, no compile error
await getOrdersForUser(productId, userId);

1. The Brand Pattern

// src/types/branded.ts

// The core brand utility — declare a unique symbol as a phantom property
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };

// Create branded types for common primitives
export type UserId = Brand<string, 'UserId'>;
export type ProductId = Brand<string, 'ProductId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type TenantId = Brand<string, 'TenantId'>;
export type SessionToken = Brand<string, 'SessionToken'>;

// For numbers
export type Cents = Brand<number, 'Cents'>;         // Money in cents (never use float for money)
export type UnixTimestamp = Brand<number, 'UnixTimestamp'>;
export type PositiveInt = Brand<number, 'PositiveInt'>;

// Constructor functions (type assertions — use ONLY at trust boundaries)
export const UserId = (id: string): UserId => id as UserId;
export const ProductId = (id: string): ProductId => id as ProductId;
export const OrderId = (id: string): OrderId => id as OrderId;
export const TenantId = (id: string): TenantId => id as TenantId;
export const Cents = (amount: number): Cents => {
  if (!Number.isInteger(amount)) throw new Error(`Cents must be an integer, got ${amount}`);
  if (amount < 0) throw new Error(`Cents cannot be negative: ${amount}`);
  return amount as Cents;
};

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 1000+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

2. Usage at Function Boundaries

// src/services/order.service.ts
import type { UserId, ProductId, OrderId, Cents } from '../types/branded';

// Now the compiler enforces correct argument types
async function getOrdersForUser(userId: UserId, productId: ProductId): Promise<Order[]> {
  return db.order.findMany({ where: { userId, productId } });
}

// ✅ Correct usage
const userId = UserId('user-123');
const productId = ProductId('prod-456');
await getOrdersForUser(userId, productId);

// ❌ Compile error: Argument of type 'ProductId' is not assignable to parameter of type 'UserId'
await getOrdersForUser(productId, userId);

// ❌ Compile error: Argument of type 'string' is not assignable to parameter of type 'UserId'
await getOrdersForUser('user-123', 'prod-456');

// Money example: Cents prevents floating-point money bugs
function calculateTotal(subtotal: Cents, taxCents: Cents): Cents {
  return Cents(subtotal + taxCents);
}

// ❌ Compile error: can't pass number where Cents is expected
calculateTotal(9.99, 0.8); // Error! 9.99 is number, not Cents

// ✅ Must go through Cents() constructor which validates integer
calculateTotal(Cents(999), Cents(80)); // $9.99 + $0.80 = $10.79

3. Zod Integration for Runtime Validation + Branding

Branded types are compile-time only — they don't exist at runtime. Zod schemas let you validate AND brand in one step at API/input boundaries.

// src/lib/validation/branded-schemas.ts
import { z } from 'zod';
import type { UserId, OrderId, Cents, TenantId } from '../types/branded';

// Validate and brand in a single Zod schema
export const UserIdSchema = z
  .string()
  .uuid('UserId must be a valid UUID')
  .transform((val) => val as UserId);

export const OrderIdSchema = z
  .string()
  .uuid('OrderId must be a valid UUID')
  .transform((val) => val as OrderId);

export const TenantIdSchema = z
  .string()
  .uuid()
  .transform((val) => val as TenantId);

export const CentsSchema = z
  .number()
  .int('Amount must be in cents (integer)')
  .nonnegative('Amount cannot be negative')
  .transform((val) => val as Cents);

// Email: validated and branded
export type ValidatedEmail = Brand<string, 'ValidatedEmail'>;
export const EmailSchema = z
  .string()
  .email()
  .toLowerCase()
  .transform((val) => val as ValidatedEmail);

// URL: validated and branded
export type SafeUrl = Brand<string, 'SafeUrl'>;
export const UrlSchema = z
  .string()
  .url()
  .transform((val) => val as SafeUrl);

// Slug: lowercase alphanumeric + hyphens
export type Slug = Brand<string, 'Slug'>;
export const SlugSchema = z
  .string()
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with hyphens')
  .transform((val) => val as Slug);

// Usage: parse at API route boundary once
export const CreateOrderSchema = z.object({
  userId: UserIdSchema,
  productId: z.string().uuid().transform((v) => v as import('../types/branded').ProductId),
  amountCents: CentsSchema,
  email: EmailSchema,
});

type CreateOrderInput = z.output<typeof CreateOrderSchema>;

// All fields are now branded — no casting needed inside the service
async function createOrder(input: CreateOrderInput): Promise<OrderId> {
  // input.userId: UserId ✅
  // input.amountCents: Cents ✅
  // input.email: ValidatedEmail ✅
  const order = await db.order.create({ data: input });
  return order.id as OrderId;
}

typescript - TypeScript Branded Types: Nominal Typing, Type-Safe IDs

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

4. Newtype Pattern for Domain Primitives

For more complex branded types with associated operations:

// src/domain/money.ts

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

// Newtype: Money with currency
interface Money {
  readonly amount: Cents;
  readonly currency: 'USD' | 'EUR' | 'GBP';
}

type Cents = Brand<number, 'Cents'>;

// Smart constructors + operations
const Money = {
  of(amount: number, currency: 'USD' | 'EUR' | 'GBP'): Money {
    if (!Number.isInteger(amount)) throw new Error('Amount must be integer cents');
    if (amount < 0) throw new Error('Amount cannot be negative');
    return { amount: amount as Cents, currency };
  },

  usd(cents: number): Money {
    return Money.of(cents, 'USD');
  },

  add(a: Money, b: Money): Money {
    if (a.currency !== b.currency) {
      throw new Error(`Cannot add ${a.currency} and ${b.currency}`);
    }
    return { amount: (a.amount + b.amount) as Cents, currency: a.currency };
  },

  multiply(money: Money, factor: number): Money {
    return { amount: Math.round(money.amount * factor) as Cents, currency: money.currency };
  },

  format(money: Money): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: money.currency,
    }).format(money.amount / 100);
  },
};

// Usage
const price = Money.usd(2999);          // $29.99
const tax = Money.multiply(price, 0.08); // 8% tax → $2.40
const total = Money.add(price, tax);     // $32.39

console.log(Money.format(total)); // "$32.39"

5. Branded Types with Prisma

// src/lib/db/mappers.ts
// Map Prisma models (plain strings) to branded types at the DB boundary

import type { User as PrismaUser, Order as PrismaOrder } from '@prisma/client';
import type { UserId, OrderId, TenantId } from '../types/branded';

interface User {
  id: UserId;
  tenantId: TenantId;
  email: string;
  name: string;
}

interface Order {
  id: OrderId;
  userId: UserId;
  tenantId: TenantId;
  amountCents: Cents;
}

// Single brand-casting point — only place you use `as`
function mapUser(raw: PrismaUser): User {
  return {
    id: raw.id as UserId,
    tenantId: raw.tenantId as TenantId,
    email: raw.email,
    name: raw.name,
  };
}

function mapOrder(raw: PrismaOrder): Order {
  return {
    id: raw.id as OrderId,
    userId: raw.userId as UserId,
    tenantId: raw.tenantId as TenantId,
    amountCents: Cents(raw.amountCents),  // Validates integer + brands
  };
}

// All code above the mapper layer uses branded types safely
export async function getUserById(id: UserId): Promise<User | null> {
  const raw = await db.user.findUnique({ where: { id } });
  return raw ? mapUser(raw) : null;
}

6. Utility Types for Collections of Branded IDs

// src/types/branded.ts (additions)

// Useful for bulk operations
export type BrandedIdSet<T extends Brand<string, string>> = ReadonlySet<T>;
export type BrandedIdArray<T extends Brand<string, string>> = ReadonlyArray<T>;

// Type guard: check if a string is a valid UUID (runtime)
export function isValidUUID(value: string): boolean {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}

// Safe brand: validates before branding (use at API boundaries)
export function safeUserId(value: unknown): UserId | null {
  if (typeof value !== 'string' || !isValidUUID(value)) return null;
  return value as UserId;
}

// Example usage in API handler
export async function GET(req: Request) {
  const url = new URL(req.url);
  const rawId = url.searchParams.get('userId');

  const userId = safeUserId(rawId);
  if (!userId) {
    return Response.json({ error: 'Invalid userId' }, { status: 400 });
  }

  // userId is now typed as UserId — safe to pass to service layer
  const user = await getUserById(userId);
  return Response.json(user);
}

Performance Impact

Branded types are purely compile-time — zero runtime overhead. The __brand property is a phantom; it only exists in the type system. The generated JavaScript is identical to unbranded code.

// TypeScript
const id: UserId = 'user-123' as UserId;

// Generated JavaScript (identical to plain string)
const id = 'user-123';

Cost Reference

ApproachType safetyRuntime safetyEngineering cost
Plain primitives (string, number)❌ None❌ None0
Type aliases (type UserId = string)❌ Structural (ineffective)❌ NoneLow
Branded types✅ Nominal (effective)❌ NoneLow–Medium
Branded + Zod✅ Nominal✅ ValidatedMedium
Branded + Zod + mappers✅ Full boundary enforcement✅ ValidatedMedium–High

More on This Topic


What Viprasol Offers

Dealing with bugs caused by swapped string arguments, unvalidated inputs, or floating-point money errors? We introduce branded types at your domain boundaries — with Zod validation, Prisma mappers, and consistent patterns — turning an entire class of runtime bugs into compile-time errors with zero performance cost.

Talk to our team → | See our web development services →

typescripttype-safetypatternsbackendbest-practicesnode.js
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.