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?".
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" }); // ok02At 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.
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.
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.
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.
Try it yourself
- 1
Break the duck
In the first example rename
speaktotalkinsideRobot(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 withProperty 'speak' is missing in type 'Robot' but required in type 'Speaker'(the live editor prints it under aTS2345line, TypeScript 7 prints it directly asTS2741). The nameRobotdidn't matter, the members did. The program still runs and the prediction comes true:Uncaught TypeError: s.speak is not a function. - 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
Remove the private
In the third example delete the word
privatefrom 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
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 HiThe 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, RaviWallet 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 }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 }.
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));.
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 makesObject.keys(obj)returnstring[]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 --extendedDiagnosticsshows 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,
#privatefields, unique symbols and enums (two enums with the same members are not assignable to each other), the common way to makeUserIdandOrderIdincompatible 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
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
Dogis only anAnimalif the class saysextends Animalorimplements Animal. In TypeScript the first example has two unrelated classes,DogandRobot, and both are accepted by a function that wants aSpeaker, simply because both havenameandspeak(). - 2
The rule behind it is assignability: a value of type
Scan be used whereTis expected ifShas at least all the members ofT, with fitting types. Extra members are fine. That is why aPoint3D(withz) can be given to something expecting aPoint2D, and the reverse fails withProperty 'z' is missing. Think ofTas a list of requirements andSas a candidate that may have more. - 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 likeageis almost certainly a typo, so it reportsTS2353: 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 aname. See the second example: same function, same extra member, two different results. - 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" }isTS2559: ... has no properties in common. Without this rule a typo would silently make every optional setting disappear. - 5
Structural typing has an important limit: private and protected members make a class behave nominally. Two classes that both declare
private balanceare 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
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
MetersandFeet, 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.login the fourth example still printsz).
Explain it without notes
What is structural typing, and how does it differ from Java or C#?
Why does greet({ name: "Ravi", age: 25 }) fail but const u = { name: "Ravi", age: 25 }; greet(u) pass?
When does TypeScript behave nominally?
What is a weak type and why does TypeScript check it specially?
Practice
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.
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
privatemembers make classes nominal.I can compare TypeScript's approach to Java or C# in two sentences.