Branded Types in TypeScript
Prevent accidental type aliasing and enforce domain constraints at compile time using branded types, factory functions, and assertion functions.
TypeScript’s structural type system treats all string values as interchangeable — a validated email, a user ID, and raw user input share the same type. Branded types fix this by adding a compile-time marker that makes structurally identical types incompatible.
The brand
A branded type intersects a primitive with a phantom property that exists only at the type level:
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
type Email = Brand<string, "Email">;
type PositiveInt = Brand<number, "PositiveInt">;
// Each type is incompatible with the others — and with its base primitive. No runtime value will ever have a __brand property. It’s a phantom — a compile-time tag that TypeScript tracks but JavaScript ignores.
Factory functions for simple wrapping
When no runtime validation is needed, a factory function is enough:
function createUserId(id: string): UserId {
return id as UserId;
}
function createPostId(id: string): PostId {
return id as PostId;
}
function getUser(id: UserId) { /* ... */ }
const userId = createUserId("user-123");
getUser(userId); // ✓
const postId = createPostId("post-456");
// getUser(postId); // ✗ Type 'PostId' is not assignable to 'UserId' Assertion functions for domain validation
When the brand carries a runtime invariant, TypeScript 3.7’s asserts keyword does double duty — runtime validation and compile-time narrowing:
function assertEmail(value: string): asserts value is Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error(`Invalid email: ${value}`);
}
}
function sendEmail(to: Email, body: string) {
// `to` is guaranteed to be validated
}
const raw = "hello@example.com";
assertEmail(raw);
// raw is now `Email` — the compiler tracks the narrowing
sendEmail(raw, "Welcome!"); // ✓
sendEmail("unvalidated@test.com", "Oops");
// ✗ Argument of type 'string' is not assignable to parameter of type 'Email' Once the assertion runs without throwing, TypeScript knows the value is branded. No redundant validation downstream.
Why brands beat manual validation
You could validate inline before every function call. That approach fails in two specific ways. First, it doesn’t compose — a function accepting Email guarantees its callers already validated, while a function accepting string forces every consumer to remember. They won’t. Second, it doesn’t survive refactors — when you rename or restructure, plain strings pass through silently, but branded types cause a compile error wherever the validation gate is missing.
Validate once at the system boundary, then pass the branded value through your entire call chain with full type safety.
Alternative: template literals
type UserId = `user_${string}`;
type PostId = `post_${string}`;
// Only works when values have distinct prefixes Template literals provide structural discrimination when IDs have predictable prefixes. Branded types work regardless of the runtime value shape.