Most TypeScript codebases type UUIDs as string. It compiles, it works, and it creates a class of bugs that only surface at runtime: passing a userId where an orderId is expected, or accepting a raw string input that was never validated as a UUID. Branded types solve the first problem; Zod (or a similar validator) solves the second. This article shows both patterns and how to combine them.

The problem with plain string

function getUser(userId: string) { /* ... */ }
function getOrder(orderId: string) { /* ... */ }

const userId = '019236a7-b4f2-7000-8d3e-9c1a2b3d4e5f';
const orderId = '019236a8-1234-7000-abcd-ef0123456789';

// TypeScript accepts this — both are string
getUser(orderId);   // Bug: passing an orderId to getUser
getOrder(userId);   // Bug: passing a userId to getOrder

Both calls compile without error. The type system has no way to distinguish between different UUID-shaped strings.

Branded types

A branded type intersects string with a unique marker that exists only in the type system:

type Brand<T, B extends string> = T & { readonly _brand: B };

type UserId  = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type PostId  = Brand<string, 'PostId'>;

At runtime UserId is just a string — the _brand property does not exist. At compile time, TypeScript treats UserId and OrderId as incompatible types.

function getUser(userId: UserId) { /* ... */ }
function getOrder(orderId: OrderId) { /* ... */ }

declare const userId: UserId;
declare const orderId: OrderId;

getUser(orderId);   // TS Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
getOrder(userId);   // TS Error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'
getUser(userId);    // OK

Creating branded UUIDs

Since the brand only exists in the type system, you need a casting function to produce typed values:

import { v7 as uuidv7 } from 'uuid';

// Type-safe constructor for each ID type
const UserId  = { new: () => uuidv7() as UserId,  parse: (s: string) => s as UserId  };
const OrderId = { new: () => uuidv7() as OrderId, parse: (s: string) => s as OrderId };

// Generate
const userId  = UserId.new();   // type: UserId
const orderId = OrderId.new();  // type: OrderId

// Parse from external input (no runtime validation yet — see below)
const fromDb = UserId.parse(row.id);

Adding runtime validation with Zod

The brand cast above is unsafe — it accepts any string. Combining with Zod adds actual UUID format validation at the boundary where data enters your system (API inputs, database reads):

import { z } from 'zod';

// Validated branded UUID schema
const UserIdSchema  = z.string().uuid().transform((s) => s as UserId);
const OrderIdSchema = z.string().uuid().transform((s) => s as OrderId);

// Parse and validate — throws ZodError if input is not a valid UUID
const userId = UserIdSchema.parse(req.params.userId);

// Safe to use — both UUID-format-validated and correctly typed
getUser(userId);

If the input is not a valid UUID string (wrong format, missing hyphens, invalid version digit), Zod throws before the cast happens. Only valid UUID strings become branded values.

Organising ID types in a project

For larger projects, collect all ID types in a single file:

// src/types/ids.ts
import { z } from 'zod';
import { v7 as uuidv7 } from 'uuid';

type Brand<T, B extends string> = T & { readonly _brand: B };

export type UserId    = Brand<string, 'UserId'>;
export type OrderId   = Brand<string, 'OrderId'>;
export type ProductId = Brand<string, 'ProductId'>;
export type SessionId = Brand<string, 'SessionId'>;

// Schemas for parsing external input
export const UserIdSchema    = z.string().uuid().transform((s) => s as UserId);
export const OrderIdSchema   = z.string().uuid().transform((s) => s as OrderId);
export const ProductIdSchema = z.string().uuid().transform((s) => s as ProductId);
export const SessionIdSchema = z.string().uuid().transform((s) => s as SessionId);

// Generators
export const newUserId    = (): UserId    => uuidv7() as UserId;
export const newOrderId   = (): OrderId   => uuidv7() as OrderId;
export const newProductId = (): ProductId => uuidv7() as ProductId;
export const newSessionId = (): SessionId => uuidv7() as SessionId;

Usage:

import { newUserId, UserIdSchema, type UserId } from '@/types/ids';

// Create
const id: UserId = newUserId();

// Parse from API
const userId = UserIdSchema.parse(ctx.params.id); // validated + typed

// Use in function signature
async function findUser(id: UserId): Promise<User | null> {
  return db.user.findUnique({ where: { id } });
}

Prisma integration

Prisma’s generated types use string for UUID fields. You can override them at the usage site:

import { UserIdSchema } from '@/types/ids';

async function getUser(rawId: string) {
  const userId = UserIdSchema.parse(rawId); // validate + brand
  return prisma.user.findUnique({ where: { id: userId } });
}

Or define a custom Prisma middleware that validates UUID fields on read:

prisma.$use(async (params, next) => {
  const result = await next(params);
  if (params.model === 'User' && result?.id) {
    result.id = UserIdSchema.parse(result.id);
  }
  return result;
});

UUID v4 vs v7 for typed IDs

The branded type pattern works identically with v4 and v7. Choose based on whether the ID appears in a public URL:

You can encode this distinction in the type:

export type InternalUserId = Brand<string, 'InternalUserId'>;  // v7
export type PublicUserId   = Brand<string, 'PublicUserId'>;    // v4

Further reading

Frequently asked questions

How do I prevent passing the wrong UUID type in TypeScript?

Use branded types — intersect string with a unique marker like type UserId = string & { readonly _brand: "UserId" }. The brand exists only in the type system (zero runtime cost) but makes UserId and OrderId incompatible, so the compiler catches ID mixups before they reach production. Combine with Zod's z.string().uuid() for runtime validation at system boundaries.

Does JavaScript have a built-in UUID generator?

Yes for v4: crypto.randomUUID() is available in all modern browsers and Node.js 19+. For v7 there is no built-in — use a library like the uuid npm package or a short RFC 9562-compliant implementation.

Should I use v4 or v7?

Use v7 for database primary keys (time-sortable, index-friendly) and v4 for anything where creation order could leak information, like tokens or share links.

What is a branded type in TypeScript?

A branded type (also called a nominal type) is a type that is structurally identical to another but treated as distinct by the TypeScript compiler. You create one by intersecting a base type with a unique marker — for example string intersected with a readonly _brand property set to a string literal like "UserId". This prevents passing a raw string or a different ID type where a UserId is expected, catching bugs at compile time with no runtime overhead.

Does Zod support UUID validation?

Yes. Zod's z.string().uuid() validator checks that a string matches the standard UUID format at runtime. Combined with a branded type, you get both compile-time type safety and runtime validation — the Zod schema parses and validates the input, and the branded type prevents ID mixups in the rest of your application code.