Branded IDs
A UserId is a string at runtime, but you never want to pass one where the code expects an OrderId.
A branded type makes that a build error: UserId and OrderId are both strings under the hood, yet
TypeScript treats them as different types, so mixing them up is caught the moment you save the file.
import type { Branded } from "@heximon/primitives";
export type UserId = Branded<string, "UserId">;
export type OrderId = Branded<string, "OrderId">;
declare function loadUser(id: UserId): Promise<User>;
const orderId = OrderId.generate();
loadUser(orderId); // build error — an OrderId is not a UserId
The brand is type-only: it exists in the type system and erases at compile time, so there is no wrapper class, no boxing, and nothing to construct at runtime. A branded id is a plain string everywhere your program actually runs — the distinctness costs you nothing.
Declare a branded id type
Branded<T, B> from @heximon/primitives tags a base type T with a string brand B. Two ids that
share a base but differ in brand are mutually unassignable — that's the whole point.
import type { Branded } from "@heximon/primitives";
// Same base type, different brands — not interchangeable.
export type UserId = Branded<string, "UserId">;
export type OrderId = Branded<string, "OrderId">;
export type ProductId = Branded<string, "ProductId">;
If a value object or entity just needs nominal distinctness and you mint its ids elsewhere, the type alias alone is enough — that's exactly how the DDD ladder example brands its user identifier:
import type { Branded } from "@heximon/primitives";
/** The branded primary-key type for a `User` — a zero-runtime nominal brand. */
export type UserId = Branded<string, "UserId">;
Branded<string, "UserId"> and a plain string interchange freely in both
directions — the distinction is only between brands. That's deliberate. The brand keeps ids from
crossing wires; it does not validate format. Validate strings you adopt from the outside at the
boundary with a schema, not with the brand.Mint and adopt ids with the Id helper
Most ids want a helper to go with the type — one place to mint a fresh id and one to adopt an inbound
string. Id<Brand>() builds that helper; the canonical idiom declares the brand type, then mints the
matching helper under the same name. Id is re-exported from @heximon/runtime, so entities and
tests keep the core import path:
import type { Branded } from "@heximon/primitives";
import { Id } from "@heximon/runtime";
export type UserId = Branded<string, "UserId">;
/** UserId.generate() mints a fresh v7 id; UserId.from(raw) adopts an inbound string as the brand. */
export const UserId: ReturnType<typeof Id<UserId>> = Id<UserId>();
That gives you two methods, both typed as the brand:
const fresh = UserId.generate(); // a brand-new, time-ordered UserId
const adopted = UserId.from(pathParam); // an inbound string, re-typed as a UserId
generate() mints a fresh, time-ordered (v7) UUID — time-ordered because that keeps freshly created ids
roughly sortable and index-friendly. from(raw) is an intent wrapper: it re-types a string you got
from the outside (a path parameter, a database row) as the brand. It performs no validation by design —
it documents that you've decided to trust this string as an id, and leaves format checking to the schema
at the boundary.
The helper is DI-free — there is no UUIDFactory provider to inject. You call it directly in entities,
value objects, scripts, and tests, which is exactly what lets the caller mint an id before dispatching
a command (the CQRS "client-generated identity" idiom — a command states intent and returns nothing, so
the id can't come back as a result).
uuid directly: uuid.v7<UserId>() (time-ordered, the
default) or uuid.v4<UserId>() (fully random). The type parameter brands the return, so the call site
reads it as the domain type with no cast.Use a branded id as an aggregate's identity
A branded id is the natural identity type for a DDD aggregate. The aggregate is
parameterized by both its props and its id brand, so its id is typed end-to-end:
import type { Branded } from "@heximon/primitives";
import { AggregateRoot } from "@heximon/domain";
import { Email } from "./email.value";
export type UserId = Branded<string, "UserId">;
interface UserProps extends Record<string, unknown> {
name: string;
email: Email;
}
export class User extends AggregateRoot<UserProps, UserId> {
public static create(id: UserId, props: { name: string; email: string }): User {
return new User(id, { name: props.name, email: Email.create(props.email) });
}
}
A domain prop can carry its own value object rather than a raw scalar — here email is an Email, which
validates the address on construction, so a User can never hold a malformed one.
Carry an id end to end
The same branded id stays one type from the moment you mint it, through the create call, into the
persistence schema, and back out the API. The controller mints a fresh UserId up front and passes it
straight to the aggregate factory:
public async create(
action: Post<"/", { body: typeof createUserSchema }>,
): Promise<TypedResponse> {
const body = await action.request.readValidatedBody();
const user = User.create(uuid.v7<UserId>(), body);
await this.users.save(user);
await this.domainEvents.emitFrom(user);
return action.respond(201, UserController.toResponse(user));
}
The brand also pins a Drizzle column's type with .$type<...>(), so a row read back from the database is
already branded — no cast at the persistence edge:
export const Users = DrizzleSQLiteEntity.define(User, "users", {
id: text("id").notNull().primaryKey().$type<UserId>(),
// …
});
One UserId runs from uuid.v7(), through the aggregate, through the Drizzle schema, to the API
response. The compiler enforces the chain at every hop, and because the brand erases at build time, all
of that safety costs exactly zero at runtime.
See also
- Schema DTOs — validate the strings you adopt with
from(...)at the request boundary, since the brand itself checks nothing. - Domain-Driven Design — where a branded id becomes an aggregate's identity type.
- CQRS — the client-generated-identity idiom that mints a branded id before a command and reads the result back with a query.
- Drizzle ORM — pin a column's type to a brand with
.$type<...>(). - L06 — DDD
— a
Useraggregate keyed by aUserId, with anEmailvalue object flattened into its own Drizzle column viavalueObject(...).
Schema DTOs
Author request and response shapes with SchemaObject and SchemaArray — one class that is both the validated TypeScript type and a Standard Schema value, with partial, pick, omit, and extend derivations.
Overview
When to reach for CQRS, Domain-Driven Design, the repository pattern, integration events, sagas, and workflows — and when a plain controller and a plain provider are still the right call.