TypeScript’s structural type system is convenient and flexible: two types are compatible when their shapes match.
That convenience can become a problem when you want different domain concepts (e.g. UserId vs ProductId) to be treated as distinct even though both are plain strings or numbers.
This post explains two effective patterns for making types nominal-like in TypeScript: branding (tagged types) and the unique property pattern (using unique symbol).
You’ll get practical code, runtime-friendly factories and guards, and guidance about trade-offs and pitfalls.
Problem: structural typing → accidental interchangeability
Example of the problem:
type UserId = string;
type ProductId = string;
function getUser(id: UserId) { /* ... */ }
const pid: ProductId = "p-123";
getUser(pid); // allowed — but this is probably a bug
Because UserId and ProductId are both string, TypeScript treats them as identical.
We need a way to make these types distinct at the type level, without adding runtime overhead or complex class wrappers.
Overview of solutions
Branding (dummy property / intersection) — add a compile-time-only phantom property (commonly
__brand) to make the type shape unique. Requires an explicit construction orascast.Unique property pattern (
unique symbol) — use aunique symbolas a property key to guarantee uniqueness across modules and third-party code.Runtime-safe factories and type guards — create values and validate them at runtime to avoid
as-driven unsafety.Use wrappers (classes/objects) if you need runtime identity/behaviour.
Simple branding (the classic trick)
A generic Brand utility makes it easy:
type Brand<K, T> = K & { __brand: T };
// Domain-specific tagged types:
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
UserId and ProductId are now structurally different because of the __brand property. You cannot assign one to the other without an explicit cast.
Creating branded values
function makeUserId(s: string): UserId {
return s as UserId; // only here do we assert; caller must use factory
}
const uid = makeUserId("u-42");
const pid = "p-42" as ProductId;
getUser(uid); // ok
getUser(pid); // error (ProductId not assignable to UserId)
Notes:
The
__brandfield is purely a compile-time trick — it does not exist at runtime.Any
ascast can subvert the safety; prefer factories or validators.
Unique symbol branding (safer)
Using a unique symbol gives stronger guarantees and prevents collisions:
declare const userIdBrand: unique symbol;
type UserId = string & { readonly [userIdBrand]: true };
This pattern ensures the key is truly unique, so other code cannot accidentally create an identical-looking brand. It’s more robust when publishing libraries or when multiple modules are involved.
Example with factory:
declare const userIdBrand: unique symbol;
type UserId = string & { readonly [userIdBrand]: true };
function makeUserId(s: string): UserId {
return s as UserId;
}
Because userIdBrand is a unique symbol, the only place that exact property key exists is where it is declared; other code cannot recreate it accidentally.
Runtime factories and type guards (recommended)
Branding alone is compile-time. If you want runtime validation (recommended for input coming from network / user), combine brands with factories and guards.
Factory + Guard example
// branding
type UserId = Brand<string, "UserId">;
function makeUserId(s: string): UserId {
if (!/^u-\d+$/.test(s)) throw new Error("invalid user id");
return s as UserId;
}
function isUserId(x: unknown): x is UserId {
return typeof x === "string" && /^u-\d+$/.test(x);
}
// usage
const raw = getFromNetwork();
if (isUserId(raw)) {
// raw is now narrowed to UserId
useUserId(raw);
} else {
// handle invalid input
}
This pattern enforces domain constraints at runtime and gives you genuine safety when data crosses boundaries.
Branded types with generic utilities
A reusable Brand helper:
type Brand<K, T extends string> = K & { readonly __brand: T };
// make factory creator
function brandFactory<K, T extends string>(tag: T) {
return {
of(value: K) { return value as Brand<K, T>; },
};
}
const UserIdFactory = brandFactory<string, "UserId">("UserId");
type UserId = Brand<string, "UserId">;
const id = UserIdFactory.of("u-1");
This is flexible for many domain IDs. But remember: these branded types are still erased at runtime.
Branded types in unions, generics, collections
Branding usually composes well with arrays, generics and unions:
type OrderId = Brand<string, "OrderId">;
type Id = UserId | OrderId;
function useId(id: Id) {
// structural checks and discriminants still behave as expected
}
const arr: UserId[] = [UserIdFactory.of("u-1"), UserIdFactory.of("u-2")];
Caveat: when using mapping types or serialization helpers, be aware that the brand does not exist at runtime and will be lost on JSON round-trip.
Serialization and JSON
Because brands are compile-time-only, JSON conversions strip the brand. Always re-validate or re-construct branded values from parsed JSON:
// from server:
const parsed = JSON.parse(payload);
const uid = makeUserId(parsed.id); // re-validate and brand
Never assume a raw string from external input is a properly-branded value.
Best practices
Provide small factory functions (
makeX) and type guards (isX) for each branded type.Validate inputs at the boundary (HTTP handlers, file parsers) and convert raw data into branded values immediately.
Prefer
unique symbolfor widely-distributed libraries or when collisions might occur.For internal codebases, a simple
Brand<K, "Name">plus disciplined factories is usually sufficient.Document the expected runtime shape and provide examples for consumers.
💡 Have questions? Drop them in the comments!
Top comments (0)