An OrderId, an email address, and a distance may all be represented by primitives. That does not make them interchangeable. Passing a UserId to loadOrder, displaying an unvalidated email address, or adding milliseconds to seconds are domain errors that happen to satisfy JavaScript’s runtime rules.
TypeScript catches many mistakes, but its structural type system asks whether two values have the same shape. Two aliases of string have exactly the same shape, so the compiler cannot infer that they carry different meanings. Branded types add a compile-time marker to a primitive or object. Combined with smart constructors, that marker records evidence: this value crossed a validation boundary and is now suitable for a specific domain operation.
Brands are not runtime security and they do not make invalid input disappear. They are a lightweight way to make trusted and untrusted states visible in function signatures. Used at the right boundaries, they prevent broad classes of accidental substitution without forcing every value into a class hierarchy.
Structural Typing Needs Domain Evidence
TypeScript is structurally typed. Compatibility depends on members rather than declared names, which is convenient for ordinary objects and library composition. For primitive aliases, however, the distinction is documentation only:
type UserId = string;
type OrderId = string;
declare function loadOrder(id: OrderId): Promise<Order>;
const userId: UserId = 'usr_7f2a';
loadOrder(userId); // Compiles: both aliases are strings.
A brand intersects the underlying value with a property whose key is a unique symbol. Nothing outside the defining module can accidentally provide that property, and differently branded values stop being assignable even when their runtime representation is identical.
declare const userIdBrand: unique symbol;
declare const orderIdBrand: unique symbol;
export type UserId = string & {
readonly [userIdBrand]: 'UserId';
};
export type OrderId = string & {
readonly [orderIdBrand]: 'OrderId';
};
declare function loadOrder(id: OrderId): Promise<Order>;
declare const userId: UserId;
// @ts-expect-error A UserId is not evidence of a valid OrderId.
loadOrder(userId);
The symbol properties exist only in the type system because the symbols are declared, not attached to string objects. At runtime, a UserId remains an ordinary string: comparison, map keys, JSON encoding, and database drivers continue to work as before. This is why branded primitives are often described as opaque or nominal-like values. Consumers can use the representation, but they cannot construct the trusted type through normal assignment.
Keep brand symbols private to their module. Export the branded type and approved constructors, not the key that could let callers manufacture structurally compatible objects. The opacity is a module-design property as much as a type trick.
Note
A brand distinguishes meanings; it does not validate data by itself. string & Brand is trustworthy only when every legitimate path into that type enforces the promised invariant.
Smart Constructors Turn Checks into Evidence
The cast required to create a branded primitive should live beside its validation. A discriminated result type makes expected input failures explicit and avoids throwing from routine parsing paths.
type ValidationError = {
code: 'required' | 'format' | 'range';
message: string;
};
type Result<T> =
| { ok: true; value: T }
| { ok: false; errors: ValidationError[] };
declare const emailBrand: unique symbol;
declare const nonEmptyBrand: unique symbol;
export type EmailAddress = string & {
readonly [emailBrand]: 'EmailAddress';
};
export type NonEmptyString = string & {
readonly [nonEmptyBrand]: 'NonEmptyString';
};
export function emailAddress(input: unknown): Result<EmailAddress> {
if (typeof input !== 'string') {
return {
ok: false,
errors: [{ code: 'format', message: 'Email must be a string' }],
};
}
const normalized = input.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
return {
ok: false,
errors: [{ code: 'format', message: 'Email format is invalid' }],
};
}
return { ok: true, value: normalized as EmailAddress };
}
export function nonEmptyString(input: unknown): Result<NonEmptyString> {
if (typeof input !== 'string' || input.trim().length === 0) {
return {
ok: false,
errors: [{ code: 'required', message: 'Value is required' }],
};
}
return { ok: true, value: input.trim() as NonEmptyString };
}
The assertions are deliberate and local. TypeScript cannot prove that a regular expression establishes EmailAddress, so the constructor closes that gap after the runtime check. Application code never needs to repeat the regex or assertion. If the email policy changes, one constructor and its tests define the new contract.
Factories for domain aggregates can now require evidence instead of rechecking loose primitives:
type Customer = Readonly<{
id: UserId;
displayName: NonEmptyString;
email: EmailAddress;
}>;
function registerCustomer(
id: UserId,
displayName: NonEmptyString,
email: EmailAddress,
): Customer {
return { id, displayName, email };
}
This keeps the core model in a state where impossible combinations are harder to express. It also clarifies where normalization occurs. For example, lowercasing an email may be appropriate for a product’s identity policy but wrong for a case-sensitive external identifier. The smart constructor makes that policy inspectable rather than scattering it across controllers.
Warning
Do not export unsafeEmail(value: string): EmailAddress as a convenience. Once an unchecked constructor becomes common, the brand means only “someone asserted this,” and every downstream signature overstates its guarantee.
IDs and Units Must Not Cross Wires
Identifiers are the simplest high-value use case. Their formats may overlap, especially when a database uses UUIDs for every table. Distinct brands prevent a valid UUID for one entity from selecting another entity by mistake.
declare const userIdBrand: unique symbol;
declare const orderIdBrand: unique symbol;
type UserId = string & { readonly [userIdBrand]: true };
type OrderId = string & { readonly [orderIdBrand]: true };
const idPattern = /^(usr|ord)_[a-z0-9]{8}$/;
function userId(input: unknown): Result<UserId> {
if (typeof input !== 'string' || !idPattern.test(input) || !input.startsWith('usr_')) {
return {
ok: false,
errors: [{ code: 'format', message: 'Invalid user ID' }],
};
}
return { ok: true, value: input as UserId };
}
function orderId(input: unknown): Result<OrderId> {
if (typeof input !== 'string' || !idPattern.test(input) || !input.startsWith('ord_')) {
return {
ok: false,
errors: [{ code: 'format', message: 'Invalid order ID' }],
};
}
return { ok: true, value: input as OrderId };
}
Units expose the same problem with numbers. A reusable quantity brand records the unit while retaining number ergonomics:
declare const quantityBrand: unique symbol;
type Quantity<Unit extends string> = number & {
readonly [quantityBrand]: Unit;
};
type Meters = Quantity<'meters'>;
type Kilometers = Quantity<'kilometers'>;
type Seconds = Quantity<'seconds'>;
type Milliseconds = Quantity<'milliseconds'>;
function meters(value: number): Result<Meters> {
if (!Number.isFinite(value) || value < 0) {
return {
ok: false,
errors: [{ code: 'range', message: 'Meters must be finite and non-negative' }],
};
}
return { ok: true, value: value as Meters };
}
function kilometersToMeters(value: Kilometers): Meters {
return (value * 1_000) as Meters;
}
function elapsedSeconds(start: Milliseconds, end: Milliseconds): Seconds {
return ((end - start) / 1_000) as Seconds;
}
declare function scheduleRetry(delay: Milliseconds): void;
declare const timeout: Seconds;
// @ts-expect-error Convert seconds before scheduling milliseconds.
scheduleRetry(timeout);
Arithmetic is a sharp edge. Operators return plain number, because TypeScript cannot know whether multiplication preserves, removes, or changes a unit. Keep arithmetic in named domain functions such as kilometersToMeters and elapsedSeconds; their small internal assertions document the dimensional transformation. For physics-heavy code with compound units, a dedicated units library is more appropriate than an expanding collection of casts.
Trust Boundaries Remove and Restore Brands
Network payloads, environment variables, form fields, message queues, database rows, and JSON.parse results are untrusted. JSON cannot carry a TypeScript brand, and a type annotation on a fetch response does not validate anything. Treat serialization as a deliberate loss of evidence, then restore evidence by decoding.
Define transport types with plain serializable values. Decode them field by field, collecting failures where useful:
type OrderDto = {
id: string;
customerId: string;
distanceMeters: number;
};
type Order = Readonly<{
id: OrderId;
customerId: UserId;
distance: Meters;
}>;
function decodeOrder(input: unknown): Result<Order> {
if (typeof input !== 'object' || input === null) {
return {
ok: false,
errors: [{ code: 'format', message: 'Order must be an object' }],
};
}
const record = input as Record<string, unknown>;
const parsedOrderId = orderId(record.id);
const parsedCustomerId = userId(record.customerId);
const parsedDistance =
typeof record.distanceMeters === 'number'
? meters(record.distanceMeters)
: {
ok: false as const,
errors: [{ code: 'format' as const, message: 'Distance must be a number' }],
};
if (!parsedOrderId.ok || !parsedCustomerId.ok || !parsedDistance.ok) {
return {
ok: false,
errors: [
...(!parsedOrderId.ok ? parsedOrderId.errors : []),
...(!parsedCustomerId.ok ? parsedCustomerId.errors : []),
...(!parsedDistance.ok ? parsedDistance.errors : []),
],
};
}
return {
ok: true,
value: {
id: parsedOrderId.value,
customerId: parsedCustomerId.value,
distance: parsedDistance.value,
},
};
}
function encodeOrder(order: Order): OrderDto {
return {
id: order.id,
customerId: order.customerId,
distanceMeters: order.distance,
};
}
The decoder’s object cast does not claim domain validity; it only enables indexed inspection after the runtime object check. The actual evidence comes from each smart constructor. Encoding goes the other way: branded primitives are assignable to their base types, so converting a trusted model to a DTO is straightforward.
At a repository boundary, do not type a driver result directly as Order. Type it as unknown or a row DTO and decode it. Databases constrain some invariants, but migrations, manual edits, legacy rows, and nullable joins can still violate assumptions in application memory.
Tip
Parse once at the edge and pass branded values inward. Revalidating at every internal function adds noise; accepting raw primitives everywhere throws away the evidence the edge already established.
Testing, Ergonomics, and the Limits of Brands
Test both runtime validation and compile-time separation. Runtime tests should cover accepted values, normalization, boundary values, malformed input, and round trips. Property-based tests are valuable for constructors: every successful result must satisfy the invariant, and encoding then decoding a domain value should preserve its meaning.
describe('userId', () => {
it('accepts a valid user ID', () => {
expect(userId('usr_a1b2c3d4')).toEqual({
ok: true,
value: 'usr_a1b2c3d4',
});
});
it.each(['', 'ord_a1b2c3d4', 'usr_TOO_SHORT'])('rejects %s', (input) => {
expect(userId(input).ok).toBe(false);
});
});
declare const validUserId: UserId;
declare function findUser(id: UserId): Promise<Customer>;
findUser(validUserId);
// @ts-expect-error Order IDs cannot enter the user repository.
findUser('ord_a1b2c3d4' as OrderId);
Run type tests under the project’s normal TypeScript build so @ts-expect-error fails if the unwanted assignment ever starts compiling. Avoid testing a brand only with as: assertions can force almost any relationship and therefore bypass the feature under test.
Brands have an ergonomic cost. They introduce constructors, result handling, generic error messages, and friction with third-party APIs expecting primitives. Reduce that cost with consistent naming (userId, emailAddress), reusable Result combinators, inference-friendly factories, and conversion functions at integrations. Do not brand every string. Brand values whose confusion or invalidity has a real domain consequence.
| Approach | Runtime validation | Runtime behavior | Best fit | Main tradeoff |
|---|---|---|---|---|
| Type alias | None | Primitive | Readability only | No separation |
| Branded primitive | Via constructor | Primitive | IDs, validated strings, simple units | Assertions must stay controlled |
| Value-object class | Constructor and methods | Class instance | Behavior-rich values and invariants | Allocation, serialization, framework friction |
| Schema library | Parser-defined | Usually plain data | Complex external payloads | Dependency and schema/type coordination |
Classes are better when a value owns substantial behavior, needs private runtime state, requires instanceof, or must guarantee invariants even to plain JavaScript consumers. A Money class with currency-aware arithmetic and rounding rules is often clearer than two intersected primitives and many free functions.
Schema libraries such as Zod, Valibot, or ArkType are better when nested payloads, unions, coercion, defaults, detailed error paths, or generated schemas dominate the problem. They can produce branded output too: use the schema at the trust boundary and keep domain signatures narrow inside. Runtime schemas and brands are complements, not competitors.
The most dangerous escape hatch is value as UserId, especially double assertions such as value as unknown as UserId. Reserve an assertion for a tiny constructor or a proven unit conversion. During code review, a domain-brand assertion outside those locations should trigger the same scrutiny as disabling a validation rule.
Takeaways
- Structural typing compares shapes, so primitive aliases cannot prevent semantically different values from being exchanged.
- A private
unique symbolbrand adds nominal-like evidence without changing the primitive at runtime. - Smart constructors earn the brand by validating and normalizing unknown input in one auditable place.
- IDs and units benefit immediately because accidental substitution is common and expensive.
- Serialization removes compile-time evidence; decoders must validate DTOs before restoring branded domain values.
- Test constructor behavior and forbidden assignments, and keep assertions confined to reviewed creation or conversion functions.
- Prefer classes for behavior-rich runtime value objects and schema libraries for complex external data.
Branded types work best as modest, explicit claims. They do not prove that the whole system is correct; they let one boundary prove a useful fact once and let the rest of the program rely on it. That small shift turns many comments and naming conventions into compiler-enforced domain rules.