Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner ~23 min· topic 5 of 6

Topic 3.5

Structural Typing

In one line

TypeScript decides whether a value fits a type by looking at its shape, the members it has, and not at the name of its class or interface. This one idea explains why code that "shouldn't" compile does, and why some obvious-looking errors appear.

Think of it like this

A power plug. A socket doesn't ask "which company made you?", it checks the shape of the pins. Any plug with the right pins fits, even from another brand. TypeScript sockets check pins, not brand names. If a value has every member a type asks for, it fits, no matter what it was called or how it was made.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Structural typing
Deciding whether a value fits a type by comparing the members it has, not the name it was declared with.
Nominal typing
Deciding by declared name. Java and C# work this way. TypeScript does it only in a few places: classes with private or protected members, unique symbols and enums.
Duck typing
"If it walks like a duck and quacks like a duck, it is a duck." Treating a value by what it can do, not what it is called.
Assignable
Allowed to be used where another type is expected: the value has at least all the required members with fitting types.
Fresh object literal
An object written directly in the place it is checked, like a function argument. TypeScript checks it for extra members.
Excess property check
The extra check on fresh object literals that reports members the target type does not know about.
Weak type
A type whose members are all optional. TypeScript demands at least one property in common with it.
Private member
A class member marked private. It makes the class nominal: only values built from that very class declaration fit.

Step by step

01Two unrelated classes, one interface

Dog and Robot don't mention Speaker, and don't share a parent. They both have a name and a speak() method returning text, so both fit.

A plain object with the same members fits too. The checker never asks "who made you?".

duck.tswhole filets
interface Speaker { name: string; speak(): string }

class Dog   { name = "Rex"; speak() { return "Woof"; } }
class Robot { name = "R2";  speak() { return "Beep"; } }

function introduce(s: Speaker) { return s.name + " says " + s.speak(); }

introduce(new Dog());                              // ok
introduce(new Robot());                            // ok
introduce({ name: "Plain", speak: () => "Hi" });   // ok

02At least all the required members

A value with more members than needed fits. A value with fewer does not. Think "requirements list".

Point3D has everything Point2D asks for, so it is assignable. Point2D lacks z, so it is not.

subset.tswhole filets
interface Point2D { x: number; y: number }
interface Point3D { x: number; y: number; z: number }

const p3: Point3D = { x: 1, y: 2, z: 3 };
const p2: Point2D = p3;          // ok: p3 has x and y
const back: Point3D = p2;        // error TS2741: Property 'z' is missing in type 'Point2D' ...

03Fresh literals are checked strictly

An object literal written right where it is checked is probably intended to be exactly that shape, so an unknown member is reported.

The same object stored in a variable first is just "a value that has a name", so the extra member is allowed.

fresh.tswhole filets
type Named = { name: string };
function greet(n: Named) { return "Hello, " + n.name; }

const user = { name: "Asha", age: 30 };
greet(user);                           // ok: not fresh
greet({ name: "Ravi", age: 25 });      // error TS2353: ... 'age' does not exist in type 'Named'.

04Weak types need one match

If every member is optional, an object with no matching name would pass, hiding typos. TypeScript requires at least one shared property.

weak.tswhole filets
type Options = { colour?: string; size?: number };
function paint(o: Options) { return o.colour ?? "none"; }

const tool = { colur: "red" };         // typo
paint(tool);                           // error TS2559: Type '{ colur: string; }' has no properties in common with type 'Options'.

05Private members bring back names

A private member is only compatible with the same declaration. Two classes with identical private fields are different types, and no object literal can pretend to be one.

Private members bring back namesdiagram
Rendering diagram…

Try it yourself

  1. 1

    Break the duck

    In the first example rename speak to talk inside Robot (the property in the class only). Predict which line turns red and what the message will say.

    The new Robot() call fails, and the message ends with Property 'speak' is missing in type 'Robot' but required in type 'Speaker' (the live editor prints it under a TS2345 line, TypeScript 7 prints it directly as TS2741). The name Robot didn't matter, the members did. The program still runs and the prediction comes true: Uncaught TypeError: s.speak is not a function.

  2. 2

    Spread the extra property

    In the second example change the last call to greet({ ...user }). Predict whether the error returns.

    No error: a spread creates a fresh literal, but the checker does not run the excess property check on spread members, only on properties you write out.

  3. 3

    Remove the private

    In the third example delete the word private from both classes. Predict how many errors remain.

    Zero. Without private members, Account and Wallet are just two shapes with the same members, and everything fits.

Code & diagrams

Duck typing, checked before you run

No errors: none of the three values says it is a Speaker. Rename speak to talk in Robot and watch the second call fail.

Console

(nothing was printed)

Expected console output

Rex says Woof
R2 says Beep
Plain object says Hi
Same extra property, two outcomes

The program runs and prints both greetings. The error is about intent, not about runtime safety: the literal has a member the function can never read, which is usually a typo.

Console

(nothing was printed)

Expected console output

Hello, Asha
Hello, Ravi
Private members make a class nominal

Wallet has the same members as Account, but its balance is a different private declaration. An object literal cannot fake a private member either.

Console

(nothing was printed)

Expected console output

Account { balance: 0 } Wallet { balance: 0 }
A narrower type still carries its extra members

p2 is typed as Point2D, but it is the same object as p3, so z is still there at runtime. TypeScript only reasons about the members the type declares.

Console

(nothing was printed)

Expected console output

{ x: 1, y: 2, z: 3 }

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Add a stray property to a fresh object

Call greet({ name: "Ravi", age: 25 }) where greet takes { name: string }.

terminal
$ Types panel
── what you'll see ──
index.ts(9,35): error TS2353: Object literal may only specify known properties, and 'age' does not exist in type 'Named'.

Break #2

Typo in a weak type

At the end of the second example add a blank line and then: type Options = { colour?: string; size?: number };, a function paint(o: Options) { return o.colour ?? "none"; } (written over four lines), const tool = { colur: "red" }; (a typo) and console.log(paint(tool));.

terminal
$ Types panel and Console
── what you'll see ──
index.ts(16,19): error TS2559: Type '{ colur: string; }' has no properties in common with type 'Options'.
none

Myth vs fact

Myth

TypeScript classes are compared by name like in Java.

Fact

Only by shape, unless the class has private or protected members, which make it nominal.

Myth

If a type is wider than I need, TypeScript removes the extra members.

Fact

The value keeps them at runtime. Types only describe what you promise to use; extra members are still there, which matters for Object.keys, JSON.stringify and spreads.

Myth

Excess property errors mean the object is wrong.

Fact

The object may be perfectly valid. The check only guesses that a fresh literal with a name the type doesn't know is a typo.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Assignability is not transitive: { x: 1 } is assignable to {}, and {} is assignable to the weak type { a?: number }, yet { x: 1 } is not assignable to { a?: number } (TS2559). Structural typing also makes Object.keys(obj) return string[] instead of (keyof T)[]: the value may have extra keys, so TypeScript can't promise only the declared ones. This is a deliberate trade-off, not a bug.

  • ▸

    Comparing recursive structural types can be expensive; the checker caches results and assumes "related" for in-progress pairs to terminate. Deeply nested or generic-heavy types are where tsc --extendedDiagnostics shows time. TypeScript 7 runs the same algorithm natively and in parallel (--checkers), but identical results are required, so the semantics are unchanged.

  • ▸

    Because nominal checks only come from private/protected members, #private fields, unique symbols and enums (two enums with the same members are not assignable to each other), the common way to make UserId and OrderId incompatible is to intersect with a phantom marker, a technique called branding (Topic 10.4). TypeScript's own team has discussed adding real nominal types for years but structural typing remains the design.

Remember this

  1. 1

    The problem this solves: Without a clear rule for when a value fits a type, you can't predict why one object is accepted and another is refused. TypeScript's rule compares shapes, not names. This is structural typing (people also call it "duck typing, checked at compile time": if it walks like a duck and quacks like a duck, it is treated as a duck). The opposite is nominal typing, used by Java and C#, where a type is its declared name: a Dog is only an Animal if the class says extends Animal or implements Animal. In TypeScript the first example has two unrelated classes, Dog and Robot, and both are accepted by a function that wants a Speaker, simply because both have name and speak().

  2. 2

    The rule behind it is assignability: a value of type S can be used where T is expected if S has at least all the members of T, with fitting types. Extra members are fine. That is why a Point3D (with z) can be given to something expecting a Point2D, and the reverse fails with Property 'z' is missing. Think of T as a list of requirements and S as a candidate that may have more.

  3. 3

    There is one exception that surprises everyone: fresh object literals get an excess property check. When you write an object literal directly where a type is expected, greet({ name: "Ravi", age: 25 }), TypeScript assumes you only meant the listed members, and an unknown one like age is almost certainly a typo, so it reports TS2353: Object literal may only specify known properties. Put the same object in a variable first, const user = { name: "Asha", age: 30 }; greet(user), and the extra member is allowed, because now it's just any value that has a name. See the second example: same function, same extra member, two different results.

  4. 4

    A related protection is weak type detection: a type whose members are all optional ({ colour?: string; size?: number }) would accept almost anything, so TypeScript requires that the value share at least one property with it. Passing { colur: "red" } is TS2559: ... has no properties in common. Without this rule a typo would silently make every optional setting disappear.

  5. 5

    Structural typing has an important limit: private and protected members make a class behave nominally. Two classes that both declare private balance are different types, because a private member only fits if it came from the same declaration. A plain object can't imitate a class with private members (the third example). This is also the doorway to "branded" types (Topic 10.4), which fake nominal typing for IDs.

  6. 6

    Why it matters: structural typing is why TypeScript can describe existing JavaScript, where objects are built by hand with no class hierarchy, and why mocks and test doubles are easy (any object with the right shape works). The cost is that two types with the same shape but different meaning, like Meters and Feet, are interchangeable. And remember assignability looks only at the members you declared: a value assigned to a narrower type still has its extra members at runtime (console.log in the fourth example still prints z).

Explain it without notes

01

What is structural typing, and how does it differ from Java or C#?

02

Why does greet({ name: "Ravi", age: 25 }) fail but const u = { name: "Ravi", age: 25 }; greet(u) pass?

03

When does TypeScript behave nominally?

04

What is a weak type and why does TypeScript check it specially?

Practice

01

Write an interface HasLength { length: number } and a function longest that takes two HasLength values and returns the one with the greater length. Call it with two strings and with two arrays, and explain why both work.

02

Given function setUser(u: { id: number }) {}, show two ways to pass an object with an extra name member without a TypeScript error, and one way that is an error.

Trade-offs

  • ↔

    Structural typing lets unrelated code work together and makes tests easy, but it cannot tell two same-shaped values with different meaning apart. Add brands or classes with private members where mixing them would be dangerous.

  • ↔

    The excess property check catches typos but only on fresh literals. Do not rely on it as a security or validation boundary: runtime data needs runtime checks (Topic 13.4).

Done when you can

  • I can explain structural typing with the plug-and-socket idea.

  • I can explain why a value with extra members is assignable but one with fewer is not.

  • I can say when the excess property check applies and when it doesn't.

  • I can explain how private members make classes nominal.

  • I can compare TypeScript's approach to Java or C# in two sentences.