Structural typing and assignability rules

TypeScript · Expert · 15 min

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 that f({...}) rejects.
  • Weak types. If every property of T is optional, S must share at least one property with it — otherwise { colour: "red" } would silently satisfy { color?: string; size?: number }.
  • Top types. unknown accepts everything. {} accepts everything except null and undefined (even 42, since numbers have properties). object accepts 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; object only 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; use Record<string, never> instead.

Check yourself

  1. Which value can be assigned to a variable of type {}?
  2. Why does const x = 5 as string fail while const y = 5 as unknown as string compiles?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/type-compatibility.html