Heximon Logo
Capabilities

Branded IDs

Nominal entity identifiers with the zero-runtime Branded type and the DI-free Id helper — generate, from, uuid.v7, uuid.v4, $type, and end-to-end id safety.

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:

src/users/user.entity.ts
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">;
The brand is weak: a 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:

src/users/user.ts
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).

For a one-off id without a declared helper, call 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:

src/users/user.entity.ts
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:

src/users/user.controller.ts
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:

src/users/user.schema.ts
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 User aggregate keyed by a UserId, with an Email value object flattened into its own Drizzle column via valueObject(...).
Copyright © 2026