What you’ll learn
- The problem: every
stringis assignable to every otherstring- 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.