EduFlow

Section 1 · Types as a Design Tool

Structural Typing and Assignability

17 min4 min readCode playground

Lesson at a glance

Predict when TypeScript accepts one type where another is expected, including excess property checks and function parameters, so assignability errors stop surprising you.

  1. 1Types are sets of values
  2. 2Excess property checks: the one exception
  3. 3Functions: parameters flip the direction
  4. 4When you want names to matter

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Here is a function from Cartwheel’s checkout client and a call that surprises people coming from Java or C#:

TypeScript
type Money = { amountCents: number; currency: string };

function formatMoney(m: Money): string {
  return `${(m.amountCents / 100).toFixed(2)} ${m.currency}`;
}

const tent = { sku: 'TENT-2P', name: 'Two-person tent', amountCents: 18900, currency: 'USD' };
formatMoney(tent); // compiles: "189.00 USD"

tent was never declared as Money. It is a product. TypeScript accepts it anyway, because TypeScript does not ask what something is called. It asks whether it has the right shape. This is structural typing, and once you see it clearly, most “why does this compile?” moments go away.

Types are sets of values

The most useful mental model for this whole course: a type is a set of values. string is the set of all strings. 'USD' is the set with one value in it. 'USD' | 'EUR' has two.

Assignability is the subset relation. A value of type A can go where B is expected when every A is also a B. Every 'USD' is a string, so 'USD' is assignable to string. Not every string is 'USD', so the reverse needs a check.

Object types work the same way, with a twist that trips people up. { id: number } is the set of all values that have a numeric id. { id: number; status: string } adds a second condition, so fewer values qualify. More properties means a smaller set. That is why a full Order can be passed where { id: number } is expected: it is in the smaller set, so it is in the bigger one too.

Two nested-set diagrams. Left: unknown contains string, which contains the union of USD and EUR, which contains USD. Right: an object with id contains an object with id and status, which contains a full Order. Every value in an inner set also belongs to the sets around it. unknown string 'USD' | 'EUR' 'USD' { id } { id, status } Order id, status, total…
Assignability is the subset relation: narrower types fit inside wider ones

Two special types sit at the edges. unknown is the set of every value: anything is assignable to it, and you must check before using it. never is the empty set: it is assignable to everything (the empty set is a subset of every set), and nothing is assignable to it. You will lean on both later in the course. any is not a set at all; it switches checking off in both directions, which is why one stray any can quietly poison everything it touches.

Excess property checks: the one exception

If extra properties are fine, why does this fail?

TypeScript
type Order = { id: number; status: string };
function save(order: Order) {}

save({ id: 1001, stauts: 'processing' });
// Error: Object literal may only specify known properties, but 'stauts'
// does not exist in type 'Order'. Did you mean to write 'status'?

This is an excess property check, a deliberate heuristic layered on top of structural typing. When you write an object literal directly where a type is expected, the literal is “fresh”: nothing else can ever see it, so an extra property can only be a typo. TypeScript flags it.

The check only applies to fresh literals:

TypeScript
const draft = { id: 1001, status: 'processing', stauts: 'oops' };
save(draft); // compiles: draft is not fresh

Functions: parameters flip the direction

For functions, return types follow the usual rule, but parameters go the other way. A handler that accepts any CheckoutEvent can stand in for one that only needs PaymentFailed. The reverse is unsafe: a handler that only understands PaymentFailed would crash on a shipped event. Under strict (specifically strictFunctionTypes), TypeScript rejects it:

TypeScript
type PaymentFailed = { type: 'payment-failed'; reason: string };
type OrderShipped = { type: 'order-shipped'; carrier: string };
type CheckoutEvent = PaymentFailed | OrderShipped;

type Listener = { onEvent: (e: CheckoutEvent) => void };

const failuresOnly = { onEvent: (e: PaymentFailed) => console.log(e.reason.toUpperCase()) };
const l: Listener = failuresOnly;
// Error: Type '{ onEvent: (e: PaymentFailed) => void; }' is not assignable to type 'Listener'.
//   Types of property 'onEvent' are incompatible.

Fewer parameters are also fine: (e) => {} and () => {} both fit (e: CheckoutEvent, at: Date) => void, because a function is allowed to ignore arguments. That is why orders.forEach((o) => …) works even though forEach passes three arguments.

The method-syntax loophole

Now write the same type with method syntax:

TypeScript
interface Listener {
  onEvent(e: CheckoutEvent): void; // method syntax
}
const l: Listener = failuresOnly; // compiles!

Parameters of methods declared this way are checked bivariantly, even under strict. TypeScript keeps this on purpose so that everyday patterns, such as treating an Array<Dog> as an Array<Animal>, keep compiling. The cost is that the unsafe listener above is accepted.

When you want names to matter

Sometimes structural typing is too generous. An OrderId and a CustomerId are both number, so nothing stops you swapping them. The fix is to make the shapes differ, which is what branded types do in Branded Types and Exhaustive Checks. For now, the rule of thumb is enough: if two things have the same shape, TypeScript considers them the same thing.

The exercise below fixes the listener loophole in Cartwheel’s event bus. Next, you will see how TypeScript narrows a wide type inside a function, the other half of working with sets.

Key takeaways

  • TypeScript compares shapes, not names; any value with the required properties is assignable, whatever its type is called.
  • Read a type as the set of values it allows; an object type with more required properties describes a smaller set.
  • Excess property checks only apply to fresh object literals, which is why passing a variable with an extra field compiles.
  • Function parameters are checked in the opposite direction, but methods declared with method syntax are checked bivariantly, so prefer property syntax for callbacks.

Further reading

Check your understanding

4 questions

Question 1 of 4

Does this compile?

TypeScript
type Money = { amountCents: number; currency: string };
const product = { sku: 'TENT-2P', amountCents: 18900, currency: 'USD' };
const price: Money = product;
Question 2 of 4

Thinking of types as sets of values, which statement is true?

Question 3 of 4

Why does this compile under strict?

TypeScript
type CheckoutEvent = { type: 'paid' } | { type: 'shipped'; carrier: string };
interface Listener { onEvent(e: CheckoutEvent): void }
const l: Listener = { onEvent: (e: { type: 'shipped'; carrier: string }) => {} };
Question 4 of 4

Which call is rejected by an excess property check?

Practice

Code playground

Close the listener loophole

CheckoutListener declares onEvent with method syntax, so TypeScript checks its parameter bivariantly and happily accepts failuresOnly, a listener that would crash on an OrderShipped event. Change how onEvent is declared (not its parameter type) so that assigning failuresOnly to a CheckoutListener is a type error, while the two existing subscribe calls keep compiling and the shipped event still reaches them.

type PaymentFailed = { type: 'payment-failed'; orderId: number; reason: string };
type OrderShipped = { type: 'order-shipped'; orderId: number; carrier: string };
type CheckoutEvent = PaymentFailed | OrderShipped;

// Cartwheel's checkout event bus.
interface CheckoutListener {
  onEvent(event: CheckoutEvent): void;
}

const listeners: CheckoutListener[] = [];
const seen: string[] = [];

function subscribe(listener: CheckoutListener): void {
  listeners.push(listener);
}

function emit(event: CheckoutEvent): void {
  for (const l of listeners) l.onEvent(event);
}

// Handles every event: must keep compiling.
subscribe({ onEvent: (e) => { seen.push(`${e.type}:${e.orderId}`); } });

// A listener that takes no arguments at all: must keep compiling too.
subscribe({ onEvent: () => { seen.push('ping'); } });

// ---- Type tests (do not edit) ----
// This listener only understands PaymentFailed; an OrderShipped event would
// crash it (e.reason is undefined). Subscribing it must be a type error.
const failuresOnly = { onEvent: (e: PaymentFailed) => { seen.push(e.reason.toUpperCase()); } };
// @ts-expect-error: a PaymentFailed-only listener cannot receive every CheckoutEvent
const unsafe: CheckoutListener = failuresOnly;

emit({ type: 'order-shipped', orderId: 1042, carrier: 'DHL' });
console.log(seen);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors: the unsafe listener is rejected
  • Both safe listeners still receive the shipped event