Theme 01 · Type-Safety Maximalist, But Pragmatic
Branded types
Explanation
Branded types
Plain Human Explanation
Some values look identical to the computer but mean very different things to the product. A user id, team id, and Stripe customer id may all be strings, but swapping them can show the wrong data or charge the wrong account. A brand gives each value a name the type checker can remember.
Technical Explanation
A branded type is a primitive value plus a compile-time marker. It still behaves like a string or number at runtime, but TypeScript will not let you pass a TeamId where a UserId is required. Brands are usually created by small parsing or constructor functions, so raw outside values must be checked before entering trusted code.
Why It Matters
- User impact: people are less likely to see another account’s data or have actions applied to the wrong record.
- Product behavior: ids, money values, and external references keep their intended meaning across module boundaries.
- Risk: plain strings and numbers are easy to swap because TypeScript sees them as compatible.
- Decision point: use this when two values have the same runtime type but different business meaning.
The Core Move
Turn important look-alike primitives into named types at the boundary. Keep the brand small and local to the values where accidental swaps would be costly.
Small Example
Branded types: Small Example
Bad TypeScript Example
type UserId = string;
type TeamId = string;
export function addUserToTeam(userId: UserId, teamId: TeamId) {
return { userId, teamId };
}
addUserToTeam("team_123", "user_456");
type UserId = string;
type TeamId = string;
export function addUserToTeam(
userId: UserId,
teamId: TeamId,
) {
return {
userId,
teamId,
};
}
addUserToTeam("team_123", "user_456");
Good TypeScript Example
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
type UserId = Brand<string, "UserId">;
type TeamId = Brand<string, "TeamId">;
function userId(value: string): UserId {
if (!value.startsWith("user_")) throw new Error("Invalid user id");
return value as UserId;
}
function teamId(value: string): TeamId {
if (!value.startsWith("team_")) throw new Error("Invalid team id");
return value as TeamId;
}
export function addUserToTeam(userId: UserId, teamId: TeamId) {
return { userId, teamId };
}
addUserToTeam(userId("user_456"), teamId("team_123"));
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
type UserId = Brand<string, "UserId">;
type TeamId = Brand<string, "TeamId">;
function userId(value: string): UserId {
if (!value.startsWith("user_"))
throw new Error("Invalid user id");
return value as UserId;
}
function teamId(value: string): TeamId {
if (!value.startsWith("team_"))
throw new Error("Invalid team id");
return value as TeamId;
}
export function addUserToTeam(
userId: UserId,
teamId: TeamId,
) {
return {
userId,
teamId,
};
}
addUserToTeam(
userId("user_456"),
teamId("team_123"),
);
What Changed
- The bad version aliases both ids to
string, so the call can be reversed without a compiler error. - The good version gives each id a compile-time identity.
- The constructor functions create one obvious place to validate raw strings.
Realistic Example
Branded types: Realistic Example
This example uses billing values where plain numbers and strings can be mixed up easily.
Bad TypeScript Example
type Invoice = {
userId: string;
stripeCustomerId: string;
amount: number;
};
export function createInvoice(userId: string, stripeCustomerId: string, amount: number): Invoice {
return { userId: stripeCustomerId, stripeCustomerId: userId, amount };
}
type Invoice = {
userId: string;
stripeCustomerId: string;
amount: number;
};
export function createInvoice(
userId: string,
stripeCustomerId: string,
amount: number,
): Invoice {
return {
userId: stripeCustomerId,
stripeCustomerId: userId,
amount,
};
}
Good TypeScript Example
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
type UserId = Brand<string, "UserId">;
type StripeCustomerId = Brand<string, "StripeCustomerId">;
type Cents = Brand<number, "Cents">;
type Invoice = {
userId: UserId;
stripeCustomerId: StripeCustomerId;
amountCents: Cents;
};
export function createInvoice(userId: UserId, stripeCustomerId: StripeCustomerId, amountCents: Cents): Invoice {
return { userId, stripeCustomerId, amountCents };
}
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
type UserId = Brand<string, "UserId">;
type StripeCustomerId = Brand<
string,
"StripeCustomerId"
>;
type Cents = Brand<number, "Cents">;
type Invoice = {
userId: UserId;
stripeCustomerId: StripeCustomerId;
amountCents: Cents;
};
export function createInvoice(
userId: UserId,
stripeCustomerId: StripeCustomerId,
amountCents: Cents,
): Invoice {
return {
userId,
stripeCustomerId,
amountCents,
};
}
What Changed
- The bad version can accidentally store the Stripe id in the user id field.
- The good version makes each look-alike value distinct before it reaches invoice creation.
- Naming money as
Centsavoids mixing dollars, cents, percentages, and raw numbers.
System Example
Branded types: System Example
At system scale, ids cross API handlers, jobs, analytics, and webhooks. Brands keep those boundaries honest.
Larger System-Level Bad TypeScript Example
type AuditEvent = {
actorId: string;
accountId: string;
requestId: string;
};
type AuditStore = {
auditEvents: {
insert(event: AuditEvent): Promise<void>;
};
};
export async function recordAccountChange(db: AuditStore, actorId: string, accountId: string, requestId: string) {
await db.auditEvents.insert({
actorId: accountId,
accountId: requestId,
requestId: actorId,
});
}
type AuditEvent = {
actorId: string;
accountId: string;
requestId: string;
};
type AuditStore = {
auditEvents: {
insert(
event: AuditEvent,
): Promise<void>;
};
};
export async function recordAccountChange(
db: AuditStore,
actorId: string,
accountId: string,
requestId: string,
) {
await db.auditEvents.insert({
actorId: accountId,
accountId: requestId,
requestId: actorId,
});
}
Larger System-Level Good TypeScript Example
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
type ActorId = Brand<string, "ActorId">;
type AccountId = Brand<string, "AccountId">;
type RequestId = Brand<string, "RequestId">;
type AuditEvent = {
actorId: ActorId;
accountId: AccountId;
requestId: RequestId;
};
type AuditStore = {
auditEvents: {
insert(event: AuditEvent): Promise<void>;
};
};
export async function recordAccountChange(db: AuditStore, actorId: ActorId, accountId: AccountId, requestId: RequestId) {
await db.auditEvents.insert({ actorId, accountId, requestId });
}
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
type ActorId = Brand<string, "ActorId">;
type AccountId = Brand<string, "AccountId">;
type RequestId = Brand<string, "RequestId">;
type AuditEvent = {
actorId: ActorId;
accountId: AccountId;
requestId: RequestId;
};
type AuditStore = {
auditEvents: {
insert(
event: AuditEvent,
): Promise<void>;
};
};
export async function recordAccountChange(
db: AuditStore,
actorId: ActorId,
accountId: AccountId,
requestId: RequestId,
) {
await db.auditEvents.insert({
actorId,
accountId,
requestId,
});
}
What Changed
- The bad version relies on argument order even though every argument is a string.
- The good version makes each id carry its role across the system boundary.
- The audit store accepts the branded event, so downstream code cannot silently shuffle ids.
When To Use It
Branded types: When To Use It
Use This When
- Two or more values share the same primitive type but must not be swapped.
- The value crosses several functions or modules after it has been validated.
- A mix-up would affect account ownership, billing, permissions, analytics, or external provider calls.
Avoid This When
- The value is local to one tiny function and the name already makes the role obvious.
- The team would need many casts because raw values are not parsed at the boundary yet.
- The brand only repeats a field name and does not prevent a real mistake.
Tradeoffs
Brands are compile-time only. They do not validate data by themselves. Pair them with constructors or parsers, and keep the brand names boring and specific.
Related Concepts
- Parsed inputs
- Refined values
- Strict TypeScript settings
Practice Prompt
Branded types: Practice Prompt
Beginner Exercise
Find a function that accepts two or more strings or numbers with different meanings. Rename the parameters so the possible swap is obvious.
Intermediate Exercise
Create two branded types and constructor functions for those values. Update one caller so raw strings are parsed before the function call.
Stretch Exercise
Carry one brand through a small boundary, such as an API handler into a service function or a service function into a store method.
Reflection Question
What exact swap does the brand prevent, and where should raw values first become trusted branded values?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.