What you’ll learn

  • The problem: every string is assignable to every other string
  • How BrandedId creates compile-time distinct types
  • How to use branded IDs in entity schemas and function signatures
  • Common mistakes: raw strings, duplicate brands, weak schemas

The problem

function getUser(id: string) { ... }
function getOrder(id: string) { ... }

const userId = 'abc-123';
const orderId = 'def-456';

getUser(orderId);   // compiles fine — both are string
getOrder(userId);   // compiles fine — runtime bug

Every string is assignable to every other string. BrandedId breaks that.

Creating a BrandedId

import { z } from 'zod';
import { createBrandedId } from '@sotajs/ddd';

const UserId = createBrandedId({ brand: 'UserId', schema: z.string().uuid() });
type UserId = ReturnType<typeof UserId.create>;

const OrderId = createBrandedId({ brand: 'OrderId', schema: z.string().uuid() });
type OrderId = ReturnType<typeof OrderId.create>;

With branded types:

function assignOrder(userId: UserId, orderId: OrderId) { ... }

assignOrder(orderId, userId);
//          ^^^^^^^ TypeScript error: OrderId is not assignable to UserId

The compiler catches the swap. Runtime behavior is identical — both are strings. Safety is purely at compile time, which is where these bugs live.

Brand as a compile-time discriminant

The brand string exists only in the type system:

const a = UserId.create('b3f1...');
typeof a; // 'string'
a.brand;  // TypeScript error — brand doesn't exist at runtime

Using the same brand for different types:

const UserId = createBrandedId({ brand: 'EntityId', schema: z.string().uuid() });
const OrderId = createBrandedId({ brand: 'EntityId', schema: z.string().uuid() });
// Same brand — UserId and OrderId are the same type

The brand string IS the type discriminator. Use unique brands: 'UserId', 'OrderId'.

Skipping the schema:

const UserId = createBrandedId({ brand: 'UserId', schema: z.string() });
// 'admin' is a valid UserId. No format enforcement.

z.string().uuid() catches invalid formats at creation time. The narrower the schema, the fewer invalid values slip through.

BrandedId in entity schemas

const UserSchema = z.object({
  id: UserId.schema,
  username: z.string(),
});

const OrderSchema = z.object({
  id: OrderId.schema,
  userId: UserId.schema,   // cross-entity reference — type-safe
  status: z.enum(['pending', 'confirmed']),
});

Now order.props.userId is typed as UserId, not string. You can’t pass an OrderId where a UserId is expected.

BrandedId as function parameters

async function getUserOrders(userId: UserId): Promise<Order[]> { ... }
async function getOrderById(orderId: OrderId): Promise<Order> { ... }

getUserOrders(OrderId.create('...')); // ❌ TypeScript error
getOrderById(UserId.create('...'));   // ❌ TypeScript error

What you’ve learned

BrandedId makes string types distinct. UserId and OrderId are both strings at runtime, but TypeScript treats them as different types. The compiler catches cross-entity mix-ups before they reach production.

The brand string is the discriminator. Use unique brands. 'UserId', 'OrderId', 'ProductId'. Two types sharing a brand are the same type.

Schema validates at creation. Use the most restrictive schema that’s correct: z.string().uuid(), z.string().cuid2(). The narrower the schema, the fewer invalid values slip through.

Use branded IDs everywhere. Entity schemas, function signatures, repository methods — replace raw string with branded types. The compiler enforces correctness.

What’s next?

Aggregates combine entities with cross-field invariants and domain events. They’re the transactional consistency boundary. Building Aggregates covers the full lifecycle — entities, invariants, nesting, and events.

Building Aggregates