Branded types cheatsheet.
Why brand?
TS is structural — same shape = same type:
type UserId = string;
type PostId = string;
function getUser(id: UserId) {}
const pid: PostId = "abc";
getUser(pid); // no error — UserId and PostId are aliases for string
Branding makes them distinct.
Basic brand
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
const u = "abc" as UserId;
const p = "xyz" as PostId;
function getUser(id: UserId) {}
getUser(u); // ok
getUser(p); // ERROR
getUser("abc"); // ERROR (must brand)
Smart constructor
function userId(s: string): UserId {
if (!s.startsWith("u_")) throw new Error("bad id");
return s as UserId;
}
const u = userId("u_abc");
Validates at runtime, brands at compile.
Unique symbol brand (stronger)
declare const brand: unique symbol;
type Brand<T, B> = T & { [brand]: B };
Can’t be faked from outside the module.
Common branded types
type Email = Brand<string, "Email">;
type Url = Brand<string, "Url">;
type Iso8601 = Brand<string, "Iso8601">;
type PositiveInt = Brand<number, "PositiveInt">;
type Percent = Brand<number, "Percent">;
function email(s: string): Email {
if (!s.includes("@")) throw new Error("not email");
return s as Email;
}
function percent(n: number): Percent {
if (n < 0 || n > 100) throw new Error("out of range");
return n as Percent;
}
Branding number
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;
function dollarsToCents(d: Dollars): Cents {
return Math.round(d * 100) as Cents;
}
let cents = 100 as Cents;
let dollars = 1 as Dollars;
cents = dollars; // ERROR
Use to prevent unit confusion (dollars/cents, meters/feet, ms/s).
Brand + literal types
type Status = Brand<"active" | "inactive", "Status">;
function status(s: string): Status {
if (s !== "active" && s !== "inactive") throw new Error("bad status");
return s as Status;
}
Result<Branded, Error>
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function tryEmail(s: string): Result<Email> {
if (!s.includes("@")) return { ok: false, error: "no @" };
return { ok: true, value: s as Email };
}
Cleaner than throwing for invalid input.
Zod + brand
import { z } from "zod";
const Email = z.string().email().brand<"Email">();
type Email = z.infer<typeof Email>;
const e = Email.parse("[email protected]"); // Email
Zod’s .brand() produces a TS brand from the schema.
Type-only newtype (no runtime cost)
type Branded<T, B extends string> = T & { readonly __brand: B };
type UserId = Branded<string, "UserId">;
// At runtime, UserId is just a string.
JSON.stringify({ id: "abc" as UserId }); // {"id":"abc"}
No __brand property exists; it’s purely structural cast hack.
Asserting brands
function assertEmail(s: string): asserts s is Email {
if (!s.includes("@")) throw new Error("not email");
}
const x: string = "...";
assertEmail(x);
x; // narrowed to Email
Stripping brand
function unbrand<T>(x: Brand<T, any>): T {
return x as T;
}
const raw: string = unbrand(emailValue);
Useful at the serialization boundary.
When to brand
- IDs that should not be mixed (UserId vs OrgId).
- Validated strings (Email, Url).
- Units (Cents, Dollars, Meters, Feet).
- Bounded numbers (Percent, PositiveInt, Probability).
- Trusted strings (SafeHtml after sanitization).
When not to
- Plain config strings.
- One-off values inside a single module.
- When the validation is trivial (e.g. just
typeof === "string").
Branding has a maintenance cost.
Common mistakes
- Branding everything — type-checker noise outweighs safety.
- Forgetting the smart constructor — brand without validation is meaningless.
- Brand without
readonly— TS may surface it as a writable field. - Mixing brand styles in the same project.
Read this next
If you want my branded-types + Zod library, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .