Back to Blog

TypeScript Branded Types: Nominal Typing, Type-Safe IDs, and Newtype Patterns

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
November 26, 2026
13 min read

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 100+ 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;
}

πŸš€ 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

See Also


Working With Viprasol

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 β†’

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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

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.