Branded Types: Nominal Typing in a Structural World
TypeScript is structural, so UserId and PostId are both number and freely interchangeable. Branding adds a phantom tag that makes them distinct, turning validated values and units into types the compiler keeps apart.
A function takes a userId: number and a postId: number. You call it with the arguments swapped. The compiler says nothing. To TypeScript both parameters have the same type and always will. This is structural typing working as designed, and it is also a silent class of bug that no amount of careful naming closes.
type UserId = number;
type PostId = number;
function fetchPost(user: UserId, post: PostId): void {}
const u: UserId = 42;
const p: PostId = 7;
fetchPost(p, u); // no error: both are just number
The aliases document intent and nothing else. TypeScript compares types by shape, so UserId and PostId reduce to the identical shape and become interchangeable. To make them distinct, the type has to carry something structurally different. That something is a brand.
Branding with a phantom property
Intersect the base type with an object that has a tag property, and the two aliases stop being assignable to each other:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<number, 'UserId'>;
type PostId = Brand<number, 'PostId'>;
function fetchPost(user: UserId, post: PostId): void {}
declare const u: UserId;
declare const p: PostId;
fetchPost(p, u);
// Argument of type 'PostId' is not assignable to parameter of type 'UserId'.
// Property '__brand' ... 'PostId' is not assignable to ... 'UserId'.
The __brand property is a phantom. It never exists at runtime; a UserId is genuinely just a number once compiled. But the type system treats number & { __brand: 'UserId' } and number & { __brand: 'PostId' } as separate types because the brand strings differ.
The cost is that a plain number is no longer a valid UserId:
const id: UserId = 42;
// Type 'number' is not assignable to type 'UserId'.
// Property '__brand' is missing in type 'number'.
That error is the point. If any literal could flow into a UserId, the brand would buy you nothing. The only way in is through a constructor that you control, where the value gets validated.
Smart constructors put the cast in one place
Branded values arrive through a function that checks an invariant and asserts the brand on the way out. The single as cast lives inside the constructor. Every caller downstream gets a value the compiler trusts.
type Email = Brand<string, 'Email'>;
function toEmail(raw: string): Email {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(raw)) {
throw new Error(`invalid email: ${raw}`);
}
return raw as Email; // the one sanctioned cast
}
const fromForm = toEmail(formInput); // Email
const broken: Email = 'not-an-email';
// Type 'string' is not assignable to type 'Email'.
The same shape works for numeric invariants. A PositiveInt cannot be constructed from a negative or fractional value without going through the gate:
type PositiveInt = Brand<number, 'PositiveInt'>;
function toPositiveInt(n: number): PositiveInt {
if (!Number.isInteger(n) || n <= 0) {
throw new RangeError(`not a positive integer: ${n}`);
}
return n as PositiveInt;
}
function repeat<T>(item: T, times: PositiveInt): T[] {
return Array(times).fill(item); // times > 0 guaranteed by the type
}
Once times is PositiveInt, repeat never has to re-check for zero or negatives. The invariant is carried in the type, established once, and the body can rely on it.
A cleaner variant returns a discriminated result instead of throwing. It composes better and pairs with the narrowing patterns in type predicates and assertion functions:
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function parseEmail(raw: string): Result<Email> {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(raw)
? { ok: true, value: raw as Email }
: { ok: false, error: 'invalid email' };
}
Uncounterfeitable brands with unique symbol
A string-keyed brand has a weakness. Anyone who knows the tag can forge a value by hand-writing the intersection. If the brand string is 'Email', a determined caller can construct 'x' as string & { __brand: 'Email' } outside the validator. For most internal code that is fine. When you want the brand to be impossible to fabricate, key it on a unique symbol declared in a module the caller cannot reach.
declare const brand: unique symbol;
type Opaque<T, Tag> = T & { readonly [brand]: Tag };
type Word = Opaque<string, 'Word'>;
Each unique symbol is a distinct type that nothing else can name. Because brand is declared, it produces no runtime artifact, and because it is not exported, code outside this module has no way to spell the key. The only path to a Word is a constructor that lives next to the declaration.
You can collapse the boilerplate into a helper that pairs a brand type with its constructor, so each opaque type ships as a unit:
function makeBrand<T, Tag extends string>(
validate: (value: T) => boolean,
tag: Tag,
) {
type Branded = Opaque<T, Tag>;
return (value: T): Branded => {
if (!validate(value)) throw new Error(`failed brand: ${tag}`);
return value as Branded;
};
}
All of this is compile-time only. After type erasure the unique symbol, the intersection, and the brand string are gone; the runtime value is the bare string or number it always was. You pay nothing for the guarantee.
Carrying proofs of validation
The most useful brands encode work you have already done so it cannot be lost. Many of the experiments here run a check before doing anything else, and that check is exactly the kind of fact a brand should preserve.
The anagram and palindrome checker only considers candidates after normalising and validating them. Wrap a validated candidate in a brand and the rest of the pipeline can demand the validated form by type:
type Candidate = Opaque<string, 'Candidate'>;
const toCandidate = makeBrand<string, 'Candidate'>(
(s) => /^[a-z]+$/.test(s.toLowerCase()),
'Candidate',
);
function isPalindrome(word: Candidate): boolean {
// word is already normalised; no re-checking, no defensive guards
const lower = (word as string).toLowerCase();
return lower === [...lower].reverse().join('');
}
The bracket validator is the same story at the level of structure rather than characters. A string that has passed the balance check can be branded Balanced, and any function that requires balanced input states that requirement in its signature:
type Balanced = Opaque<string, 'Balanced'>;
function evaluate(expr: Balanced): number {
// safe to assume every opener has its closer
return expr.length;
}
A caller cannot hand evaluate a raw string. It must route through the validator first, so an unbalanced expression can never reach the evaluator. The brand turns a runtime precondition into a compile-time one.
Units of measure fall out of the same mechanic. Brand<number, 'Meters'> and Brand<number, 'Seconds'> refuse to add to each other, so a function that wants meters cannot silently accept seconds. The arithmetic still happens on plain numbers; the brand only governs which numbers are allowed where.
Branding is the smallest tool that buys nominal typing without leaving the structural model. The base type stays what it was, the brand lives only in the checker, and a single guarded cast converts “I validated this” from a comment into a fact the compiler enforces. When you want the same guarantees attached at the boundary instead of in a constructor, the satisfies operator is the next piece to reach for.