codejump

AcademyTypeScript

Structural typing: shapes, not names

TypeScript compares shapes, not names. That is why an unrelated object type-checks, why excess properties sometimes error, and how to make a type nothing else can satisfy.

3 min readUpdated

3 exercises

Waiting at the end of this page.

Names do not matter, shapes do

In Java or C#, a type is satisfied by the classes that declare they implement it. TypeScript asks a different question: does this value have the members the type requires?

TypeScript
interface Point { x: number; y: number }

class Vector {
  constructor(public x: number, public y: number) {}
}

const p: Point = new Vector(1, 2);  // fine — Vector never mentions Point
const q: Point = { x: 1, y: 2 };    // also fine

Nothing declared a relationship. The shapes match, so the assignment is allowed. This is why implements is almost decorative in TypeScript: it checks the class against the interface at the declaration, but it is not what makes the class assignable later.

It is also why the type you wrote is usually wider than the thing you meant.

TypeScript
type UserId = string;
type OrderId = string;

function cancel(order: OrderId) { /* ... */ }

const userId: UserId = 'u_42';
cancel(userId);  // compiles. Both are string.

Two names, one type. The compiler sees string twice and has nothing to complain about.

Excess properties, and the exception that confuses everyone

If shapes only need to match, why does this error?

TypeScript
interface Options { timeout: number }

const opts: Options = { timeout: 50, retries: 3 };
// Error: Object literal may only specify known properties,
// and 'retries' does not exist in type 'Options'.

Because of a special rule that applies to object literals assigned directly, and only to them. It exists to catch typos, and it disappears the moment the value goes through a variable:

TypeScript
const raw = { timeout: 50, retries: 3 };
const opts: Options = raw;  // no error — raw is not a fresh literal

Both are structurally fine. The first one is the compiler doing you a favour; the second is the type system's actual rule. Knowing which is which saves an hour the first time a config object stops erroring after you extract it to a variable.

When you need a type nothing else satisfies

Structural typing has no way to say "this string is different from that string" — so you add something to the shape that nobody can produce by accident. That is a branded type:

TypeScript
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function toUserId(value: string): UserId {
  // The one cast in the codebase, in the one function allowed to make one.
  return value as UserId;
}

cancel(toUserId('u_42'));  // Error: UserId is not assignable to OrderId

The brand exists only in the type system — at runtime it is still a string, with no wrapper and no cost. What it buys is that the only way to get a UserId is to go through the function that validates one, which is the whole of "parse, don't validate" in three lines.

ProblemStructural answer
Two ids of the same primitive typebrand them
A typo'd property in a configrely on the excess property check — keep the literal inline
A class that must match an interfaceimplements, for the error at the declaration
A value from outside the programunknown, then narrow

The habit this should change

Stop reading a type name as a guarantee. function send(user: User) accepts anything with the right members — including objects from a different module that happen to line up, and including the object you built by spreading three others. If that matters for a particular value, make the type structurally unique. If it does not, structural typing is doing exactly what it is for: letting your code work with data it was not introduced to.

Now practice it

2 challenges · 1 quiz

Reading this page is the cheap half. These are the exercises that make you use it.