Expert TypeScript means predicting the checker's verdicts, including its deliberate holes, and simulating nominal types when needed.
Assignability is decided structurally: S is assignable to T if, for every member T requires, S has a compatible member. Names are irrelevant, and extra members on S are fine. A few rules refine this:
- Freshness. An object literal written directly where a type is expected is fresh and gets excess property checks. Once it's stored in a variable it loses freshness, which is why
const o = {...}; f(o)accepts extra keys thatf({...})rejects. - Weak types. If every property of
Tis optional,Smust share at least one property with it — otherwise{ colour: "red" }would silently satisfy{ color?: string; size?: number }. - Top types.
unknownaccepts everything.{}accepts everything exceptnullandundefined(even42, since numbers have properties).objectaccepts only non-primitives.Object(capital O) is like{}and best avoided. - Optionality.
{ a: string }is assignable to{ a?: string }, not the reverse. - Private and `#` members make classes effectively nominal: only instances of the same declaration match.
Assertions follow a looser relation, comparability: x as T is allowed if either type is assignable to the other. When neither is, you need the explicit escape hatch x as unknown as T, which should make reviewers nervous.
When structural typing is too permissive — a UserId and an OrderId are both strings — simulate nominal typing with a brand:
type UserId = string & { readonly __brand: "UserId" };A plain string isn't assignable to UserId, so values must pass through a constructor function (ideally one that validates). The brand property never exists at runtime; a declare const brand: unique symbol key makes it impossible to forge accidentally.
Example
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function userId(raw: string): UserId {
if (!/^u_\w+$/.test(raw)) throw new Error(`bad user id ${raw}`);
return raw as UserId; // the one sanctioned cast
}
function loadUser(id: UserId) { return { id, name: "Ada" }; }
const uid = userId("u_42"); // validated, branded
// @ts-expect-error -- string is not assignable to UserId
loadUser("u_42");
declare const oid: OrderId;
// @ts-expect-error -- OrderId is not assignable to UserId
if (Math.random() > 2) loadUser(oid);
interface Options { color?: string; size?: number }
const typo = { colour: "red" };
// @ts-expect-error -- weak type: no properties in common with Options
const opts: Options = typo;
const anything: {} = 42; // {} accepts primitives (not null)
// @ts-expect-error -- object rejects primitives
const nonPrimitive: object = 42;
console.log(loadUser(uid).name, anything, opts, nonPrimitive);Remember
- Assignability is member-by-member; extra members are allowed except on fresh literals
- All-optional (weak) types require at least one shared property
{}accepts any non-nullish value;objectonly non-primitives- Brands simulate nominal types for IDs, units and validated values
Watch out for
- Using
{}to mean "empty object" — it accepts strings and numbers; useRecord<string, never>instead.