Lesson 61 of 80
String-literal branding isn't the only way to fake nominal typing — unique symbol tags and private class fields do the same job with different collision guarantees and different trade-offs.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The branded types lesson showed the basic trick: attach a fake __brand field to a type so two otherwise-identical shapes stop being interchangeable. That's the entry-level version of a bigger idea. TypeScript actually gives you three distinct ways to opt a type out of structural comparison, and they don't all behave the same way once real code starts using them.
A plain string-literal brand ({ __brand: "UserId" }) is cheap but has a subtle weak spot: it's still just a property with a string value in type space. If two unrelated libraries both decide to brand their id types with the literal string "UserId", TypeScript sees the same shape and happily considers them compatible again — the exact bug branding was supposed to prevent, just one level up. unique symbol and private class fields close that gap in different ways.
// 1. String-literal brand — cheap, but collides if two modules pick the same literal
type UserId = string & { readonly __brand: "UserId" };
// 2. unique symbol brand — collision-proof, because the symbol value itself
// can only come from this exact declaration, never from a matching string
declare const userIdBrand: unique symbol;
type SafeUserId = string & { readonly [userIdBrand]: true };
function makeSafeUserId(raw: string): SafeUserId {
return raw as SafeUserId;
}
// 3. Private class fields — TypeScript compares classes nominally the moment
// they have a private or protected member, no manual brand needed at all
class Meters {
#value: number;
constructor(value: number) { this.#value = value; }
valueOf(): number { return this.#value; }
}
class Seconds {
#value: number;
constructor(value: number) { this.#value = value; }
valueOf(): number { return this.#value; }
}
function addMeters(a: Meters, b: Meters): number {
return a.valueOf() + b.valueOf();
}
declare const s: Seconds;
addMeters(s, s); // ❌ error — Seconds and Meters are structurally identical
// but each #value is a *different* private field identity
Meters and Seconds have the exact same public shape, yet TypeScript rejects the mix-up without a single __brand anywhere. Once a class declares a private (or protected) member, TypeScript stops comparing classes by shape and starts comparing them by the actual declaration identity of that private member — two #value fields from two different class bodies are never considered the same field, even if the surrounding code looks identical.
unique symbol exists specifically to close this hole, because a symbol can't be recreated from a string.__brand property at all — but only once the class has at least one private or protected member. A class with only public fields is still fully structural, brand or no brand.unique symbol brand is erased like any other type-only construct; a private class field is real (JavaScript private fields do exist at runtime, enforced by the engine) but it's providing runtime encapsulation, not the type-level nominal check — those are two different guarantees that happen to come from the same feature."You've branded a type with a string literal — can you think of a way that could still fail?" is a strong follow-up to the basic branding question, and it separates candidates who memorized the __brand pattern from ones who understand why it works and where its guarantee actually ends.