Value Objects in TypeScript: Branded Types and Smart Constructors
TypeScript's structural typing lets any string pass as a UserId or an Email. Branded types and smart constructors close that gap and make illegal states unrepresentable.
TypeScript’s structural type system is one of its best features and one of its most dangerous. If two types have the same shape, TypeScript treats them as interchangeable — which is exactly why this compiles without a single error:
function sendInvoice(userId: string, email: string) {
// ...
}
const email = "[email protected]";
const userId = "usr_8f2a";
sendInvoice(email, userId); // Arguments swapped — compiles fine, breaks in production
Both parameters are string, so TypeScript has no way to tell a user ID from an email address, or an email address from any other string that happens to be lying around. This is the exact bug class value objects exist to eliminate: a UserId and an Email should be different types, even though they’re both represented as strings underneath.
The Problem With Primitive Types as Domain Concepts
This is primitive obsession’s quieter sibling — instead of overusing primitives for lack of a proper type, the issue here is that TypeScript’s structural typing erases the type you did define the moment it’s a type alias over a primitive:
type UserId = string;
type Email = string;
function sendInvoice(userId: UserId, email: Email) {}
// Still compiles — UserId and Email are BOTH just `string` at the type level
sendInvoice(email, userId);
A plain type alias is not a new type in TypeScript’s eyes — it’s just a name for an existing one. To get real distinction, the type needs to carry information that only the domain, not the shape, can provide.
Branded (Opaque) Types
The standard technique is a brand: attach a phantom property that exists only at the type level and has no runtime representation. It costs nothing at runtime and makes the types nominally distinct at compile time.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
function sendInvoice(userId: UserId, email: Email) {
// implementation
}
declare const someUserId: UserId;
declare const someEmail: Email;
sendInvoice(someUserId, someEmail); // fine
sendInvoice(someEmail, someUserId); // Type error: Email is not assignable to UserId
Because __brand doesn’t exist at runtime, a UserId value is a plain string in the compiled JavaScript — there is zero overhead, zero serialization concern, and zero need to “unwrap” it before writing it to a database or an API response. The safety is entirely a compile-time construct.
The catch: you can’t produce a branded value just by writing a string literal — "usr_8f2a" as UserId compiles, but it also means anyone can cast their way past the brand with as, defeating the point. Branding alone gives you nominal typing; it doesn’t give you validation. For that you need a constructor.
Smart Constructors: Parse, Don’t Validate
The phrase “parse, don’t validate” describes the shift from checking that data is valid and then continuing to pass around the original (still technically “unvalidated” typed) value, to parsing the data into a type that can only exist in its valid form. A smart constructor is how you do that in TypeScript: a factory function that is the sole legitimate way to produce a value of the branded type, and that returns a Result instead of throwing, so callers are forced to handle the failure case.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
type Email = Brand<string, "Email">;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function parseEmail(input: string): Result<Email, string> {
const trimmed = input.trim().toLowerCase();
if (!EMAIL_PATTERN.test(trimmed)) {
return err(`"${input}" is not a valid email address`);
}
return ok(trimmed as Email);
}
The as Email cast happens exactly once, inside the one function allowed to perform it. Every other part of the codebase receives an Email only by going through parseEmail, which means every Email in scope is, by construction, already valid — no defensive re-validation scattered through the call chain, no “is this string actually an email by the time it reaches the billing service” uncertainty three layers deep.
type UserId = Brand<string, "UserId">;
function parseUserId(input: string): Result<UserId, string> {
if (!/^usr_[a-z0-9]{6,}$/.test(input)) {
return err(`"${input}" is not a valid user id`);
}
return ok(input as UserId);
}
Calling code has to unwrap the Result before it can use the value, which is exactly the point — the compiler pushes the error-handling decision to where the invalid input actually enters the system, instead of letting it silently propagate:
function registerUser(rawEmail: string, rawUserId: string) {
const emailResult = parseEmail(rawEmail);
const userIdResult = parseUserId(rawUserId);
if (!emailResult.ok) {
return err(`Invalid registration: ${emailResult.error}`);
}
if (!userIdResult.ok) {
return err(`Invalid registration: ${userIdResult.error}`);
}
return sendInvoice(userIdResult.value, emailResult.value);
}
Value Objects Beyond Strings: Equality and Invariants
Branded primitives cover the “distinct string/number” case, but a value object often needs to hold more than one field and enforce an invariant across them (a Money amount plus currency, a date range with start <= end). For these, a small class with a private constructor and a create factory generalizes the same pattern:
class Money {
private constructor(
readonly amountMinor: number,
readonly currency: "USD" | "EUR" | "GBP"
) {}
static create(amountMinor: number, currency: "USD" | "EUR" | "GBP"): Result<Money, string> {
if (!Number.isInteger(amountMinor)) {
return err("Money amount must be an integer number of minor units (cents)");
}
if (amountMinor < 0) {
return err("Money amount cannot be negative");
}
return ok(new Money(amountMinor, currency));
}
add(other: Money): Result<Money, string> {
if (other.currency !== this.currency) {
return err(`Cannot add ${other.currency} to ${this.currency}`);
}
return ok(new Money(this.amountMinor + other.amountMinor, this.currency));
}
equals(other: Money): boolean {
return this.amountMinor === other.amountMinor && this.currency === other.currency;
}
toString(): string {
return `${(this.amountMinor / 100).toFixed(2)} ${this.currency}`;
}
}
The private constructor is what makes this a smart constructor rather than just a class: new Money(-500, "USD") is a compile error from outside the class, so the only way to get a Money instance is through create, which enforces the invariant. Note the equals method too — value objects are compared by value, not by reference, which is easy to get wrong with plain object literals ({ amountMinor: 500, currency: "USD" } !== { amountMinor: 500, currency: "USD" } in JavaScript) and is exactly why value objects define their own equality instead of relying on ===.
Integrating With Schema Validation Libraries
Hand-rolled regex checks are fine for simple cases, but real APIs usually validate request bodies with a schema library like Zod, and the branding pattern composes with it cleanly — the schema does the parsing, and a .brand() or a .transform() step attaches the nominal type:
import { z } from "zod";
const EmailSchema = z
.string()
.trim()
.toLowerCase()
.email()
.brand<"Email">();
type Email = z.infer<typeof EmailSchema>;
function parseEmail(input: unknown): Result<Email, string> {
const parsed = EmailSchema.safeParse(input);
return parsed.success ? ok(parsed.data) : err(parsed.error.issues[0].message);
}
This gets you the same “one legitimate entry point” guarantee as the hand-written version, but with Zod’s parsing, coercion, and error-message machinery doing the heavy lifting — worth reaching for as soon as validation rules grow past a single regex.
Serialization: Brands Don’t Survive the Wire
A brand is purely a compile-time annotation, which means it evaporates the moment a value crosses a serialization boundary. JSON.stringify on a branded Email produces the same plain string as an unbranded one, and JSON.parse on the other end gives you back a bare string, not an Email — TypeScript has no way to attach the brand automatically, because there’s nothing left at runtime to attach it to.
function saveUser(email: Email) {
return fetch("/api/users", {
method: "POST",
body: JSON.stringify({ email }), // just a string on the wire
});
}
async function loadUser(id: UserId): Promise<{ email: Email }> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json(); // data.email is `any`, not `Email`
const parsed = parseEmail(data.email); // re-validate at the boundary
if (!parsed.ok) {
throw new Error(`Corrupted user record: ${parsed.error}`);
}
return { email: parsed.value };
}
The practical rule: re-run the smart constructor every time data re-enters your process from outside — an HTTP response, a database row, a queue message, localStorage. Branding protects you from mixing up values you already hold in memory; it does not, and cannot, protect you from data that hasn’t been parsed yet. Treating a brand as a substitute for validation at the boundary is the most common way teams get burned by this pattern.
When This Is Worth the Ceremony
Branding every string in a codebase is overkill and will make simple code harder to read for no benefit. Reach for it specifically at the boundaries where mixing up two same-shaped values would be a real, costly bug: identifiers that are easy to transpose (userId vs orderId vs productId), units that are easy to confuse (cents vs dollars, minutes vs seconds), and anything crossing a security or trust boundary (Email that’s been verified vs a raw string from a form). Internal, short-lived local variables where a mix-up would be caught immediately by an obvious runtime error don’t need it.
Key Takeaways
- Structural typing means
type UserId = stringgives you a readable alias, not real type safety — any string is still assignable to it. - A brand (
T & { __brand: "X" }) makes two primitive-backed types nominally distinct at compile time, with zero runtime cost. - Branding alone doesn’t validate anything — pair it with a smart constructor that is the only legitimate way to produce the branded value.
- “Parse, don’t validate”: have the constructor return a
Resultand force callers to handle invalid input at the boundary, not downstream. - For multi-field value objects, use a private constructor plus a
createfactory to enforce invariants, and defineequalsfor value-based comparison. - Zod’s
.brand()(or equivalent in other schema libraries) lets you get the same guarantee with real parsing and error messages instead of hand-rolled regexes. - Apply this pattern at true risk boundaries — swappable IDs, confusable units, trust boundaries — not to every string in the codebase.