Implement a UserId that a plain string cannot satisfy
loadUser takes a user id, but every call site passes a raw string, and last week an order id was passed instead. Give UserId an identity of its own so the compiler rejects any string that was not validated.
Constraints: no runtime wrapper object and no cost in the emitted JavaScript; there must be exactly one way to obtain a UserId, and it must validate the raw string.
type UserId = string; // replace this
function toUserId(raw: string): UserId {
// your code here
}
declare function loadUser(id: UserId): void;
Write the implementation.
Intersect the base type with a phantom field keyed by a unique symbol, so no plain string is assignable to it. Expose one factory that validates the raw value and asserts it into the branded type. The brand is type-only, so the emitted JavaScript is unchanged.
- ✗Believing a bare
typealias orstring & {}already excludes a plain string - ✗Exporting the brand so callers can hand-craft a
UserIdwith anas - ✗Reaching for a wrapper class and paying an allocation on every id
- →Why keep the branding symbol unexported from the module?
- →How would you brand a
numberid and anOrderIdwithout them colliding?
Solution
The brand is a phantom field that no plain string carries. A unique symbol key makes it unforgeable: the key cannot even be named outside the module.
declare const brand: unique symbol; // never exported
export type UserId = string & { readonly [brand]: 'UserId' };
export function toUserId(raw: string): UserId {
if (!/^u_[0-9a-f]{8}$/.test(raw)) throw new Error(`Bad user id: ${raw}`);
return raw as UserId; // the only as in the module
}
declare function loadUser(id: UserId): void;
function caller(raw: string) {
loadUser(raw); // ❌ error TS2345: string is not assignable to UserId
loadUser(toUserId(raw)); // ✅ went through validation
}
A UserId is still a string at runtime: toUserId('u_0000abcd') hands back the very same string, concatenation and .length work as usual, and the as is erased. The cost is zero.
⚠️ Each id needs its own brand (an OrderId with its own literal), or two branded types end up structurally identical and become interchangeable all over again.