Patterns
How do I stop a `UserId` being passed where an `OrderId` was expected, when both are just strings?
TypeScript is structural, so two aliases of `string` are the same type. A phantom property makes them different without changing the value.
The recipe
declare const brand: unique symbol type Brand<T, Name extends string> = T & { readonly [brand]: Name } type UserId = Brand<string, 'UserId'> type OrderId = Brand<string, 'OrderId'> const asUserId = (raw: string): UserId => raw as UserId declare function findUser(id: UserId): void const id = asUserId('u_1') findUser(id)
The build compiles this and checks each result below.
How it works
- 01
declare const brand: unique symbol
A `unique symbol` gives a key nothing else can produce. A string key would let an unrelated object accidentally satisfy the brand.
- 02
type Brand<T, Name extends string> = T & { readonly [brand]: Name }
The intersection adds a property that exists only in the type. At run time the value is still exactly the string you started with.
- 03
const asUserId = (raw: string): UserId => raw as UserId
One cast, in one place, is the price. Everywhere else the compiler enforces the distinction for free — which is why this function should be the only way in.
What you get
UserId extends string ? true : false
→truestring extends UserId ? true : false
→falseThe asymmetry is the whole feature: a branded id is still a string, but a bare string is not a branded id.
UserId extends OrderId ? true : false
→false
Where it goes wrong
The brand is erased, so `JSON.parse` will happily hand you something typed `UserId` that never went through your constructor. Brands guard your code against itself; they do not validate data crossing a boundary.
Takeaway
Reach for a brand when two values share a representation and must not share a meaning. Keep exactly one function that produces each one.