Branded Types & Assertion Functions
Advanced · 20 min read · ▶ live playground · ✦ checkpoint
Branded types let TypeScript distinguish values that have the same runtime shape but different domain meaning. A UserId and an OrderId may both be strings, but your program should not be allowed to pass one where the other belongs.
This is the lesson where as gets one honest job: after runtime validation has already run, a tiny constructor or assertion function may record a domain fact the checker cannot discover by itself.
Every string looks alike
Plain type aliases do not create new identities. If two aliases both name string, they are the same set of values to the checker:
type UserId = string;
type OrderId = string;
function loadOrdersForUser(userId: UserId): string {
return "orders for " + userId;
}
const orderId: OrderId = "order_734";
console.log(loadOrdersForUser(orderId));→ orders for order_734
That compiled because UserId and OrderId are both just strings. Structurally, they match. Domain-wise, the call is wrong.
Branding adds a type-only badge
A brand intersects a normal runtime type with a fake tag that exists only for the checker:
type Brand<Base, Name extends string> = Base & {
readonly __brand: Name;
};
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function loadOrdersForUser(userId: UserId): string {
return "orders for " + userId;
}
declare const orderId: OrderId;
console.log(loadOrdersForUser(orderId));→ playground.ts:14:31 - error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
Read Brand<string, "UserId"> as "a string that has passed the UserId check." It is still a string at runtime. The brand is a compile-time badge that prevents accidentally mixing same-shaped domain values.
Because the brand property is fake, only one place should attach it: the code that actually validates the value.
Constructors validate once
A constructor function for a brand accepts an ordinary value, checks the domain rule, and returns the branded value. The as UserId at the end is not a costume thrown over unknown data. It is an honest assertion: the function just proved the rule.
type Brand<Base, Name extends string> = Base & {
readonly __brand: Name;
};
type UserId = Brand<string, "UserId">;
function makeUserId(value: string): UserId {
if (!value.startsWith("user_") || value.length <= "user_".length) {
throw new Error("Invalid UserId: " + value);
}
return value as UserId;
}
console.log(makeUserId("user_42"));→ user_42
The emitted JavaScript shows the boundary clearly: the check remains, and the brand disappears.
function makeUserId(value) {
if (!value.startsWith("user_") || value.length <= "user_".length) {
throw new Error("Invalid UserId: " + value);
}
return value;
}
console.log(makeUserId("user_42"));That is the rule going forward: as is acceptable when it records a fact already proved by runtime code or by a pattern the checker cannot express. value as UserId without the constructor is back to 13.1's costume.
The non-null assertion operator, value!, follows the same honesty rule. It does not check runtime either. Use it only when surrounding code has already proved the value is present and the checker cannot see that proof. This lesson unlocks the tool; it does not make it a red-squiggle eraser.
Assertion functions throw or narrow
Sometimes you do not want a constructor expression. You already have a variable and want to narrow it in place. An assertion function has a return type like asserts value is UserId. If it returns, the checker narrows the variable. If the value is bad, it must throw.
→ orders for user_123
→ orders for user_999
→ rejected: Invalid UserId: order_734
→ kept order id: order_734
Study the flow:
makeUserIdandmakeOrderIdvalidate once, then apply the brand.assertUserIdnarrows an existing string after throwing on failure.- Application functions accept branded values, not plain strings.
- The runtime data stays ordinary strings; the checker carries the domain receipt.
Assertion functions are best at boundaries inside larger workflows: after a form schema proves a field is a string, assertUserId(field) can prove it is a user id. Constructors are best when you want an expression that returns either a branded value or throws.
Keep brands close to the boundary
Brands are powerful because they make invalid calls unrepresentable. They are dangerous when scattered randomly.
Keep the pattern boring:
- Parse outside data into a known shape.
- Run a domain constructor or assertion.
- Return the branded value.
- Let internal code require the brand.
Do not brand everything. A display name can stay string. A paragraph can stay string. Brand IDs, tokens, normalized email addresses, currency cents, database keys, and other values where same-shaped data can be mixed up.
The connection to 13.2 is direct: schemas prove shape, brands preserve meaning. Project 3 will combine both: derive types from form config, validate input at the boundary, and use constructors or assertions for domain values that need a receipt stronger than "this is a string."
Checkpoint
Answer all three to mark this lesson complete