TSer/04 · Structural typing
CHAPTER 04 · Shapes, not names

Structural typing

TypeScript never asks what a type is called. It compares the members a value actually has against the members the target requires. This chapter explains that rule, and then takes apart the one exception that confuses almost every beginner: the excess property check.

§01

The duck test: shape decides, not the name

A milk tea shop is hiring. The owner does not read diplomas. The only question is whether you can make tea. TypeScript judges types the same way.

An interview with no paperwork

The shop needs staff. The posting says: has a name, can make tea. Someone shows up with no certificate at all. The owner does not care. Name? Yes. Make tea? Made one on the spot. Hired. Whether that person used to be called a "full-time employee" or "the shop next door's staff" is never asked.

That is the whole idea of structural typing. Whether a value belongs to a type depends on which members the value has, not on what the value ever declared itself to be. The same idea has an older name: the duck test. If it walks like a duck and quacks like a duck, treat it as a duck.

structural.ts · an object that never claimed to be Staff
1type Size = "small" | "medium" | "large";
2
3type Staff = {
4 name: string;
5 makeTea: (size: Size) => void;
6};
7
8function startShift(s: Staff) {
9 console.log(s.name + " started the shift");
10}
11
12// note: this object never mentions Staff
13const zhen = {
14 name: "Zhen",
15 makeTea(size: Size) {
16 console.log("making a " + size);
17 },
18};
19
20startShift(zhen); // ✓ accepted, because the shape matches Staff
There is no declared link between zhen and Staff. The compiler compared the members of zhen with the members Staff requires, found all of them, and allowed the call.
The name
Only a label

Staff is a label you attached to a shape so that people can read the code. When the compiler compares types, the label is not part of the comparison.

The shape
The type itself

Which members exist, and the type of each member. That list is what the compiler treats as the type. Two types with the same list are two names for one thing.

The duck test
How the check runs

Every member the target requires is present, and each one has a compatible type. The check passes. No declaration is needed, because compatibility is computed rather than registered.

Common mistake: “A new interface name means a new type”

It does not. interface A { x: number } and interface B { x: number } are assignable to each other in both directions. The name is a label, and the shapes are the same. type behaves exactly like interface here: neither one creates a separate type just by having a separate name. If you need two same-shaped types to stay apart, §05 shows the standard way.

§02

Two designs: compare declarations, or compare members

Java and C# take the other road. They use nominal typing, and they give the opposite answer for the same code.

Java · nominal: the declared name decides
1class MilkTea {
2 String name;
3}
4class FruitTea {
5 String name;
6}
7
8// MilkTea a = new MilkTea();
9// FruitTea b = a;
10// ✕ incompatible types: MilkTea
11// cannot be converted to FruitTea
12// The members are identical.
13// The names are not, so the two
14// classes are unrelated.
TypeScript · structural: the members decide
1class MilkTea {
2 name = "";
3}
4class FruitTea {
5 name = "";
6}
7
8const a = new MilkTea();
9const b: FruitTea = a;
10// ✓ accepted: the shapes match
11// Classes take the same test.
12// One exception: see chapter 08.

A nominal type system compares declarations. Two types are related only if one of them says so, with extends or implements. A structural type system compares members. Nothing has to be declared. Look at the right-hand example again: in TypeScript, even classes are compared by shape. That is the rule developers coming from Java run into first. There is exactly one exception, and it needs a private or protected member to appear. Chapter 08 covers it.

Why TypeScript chose structural typing

Because its job is to describe JavaScript that already exists. Think about what ordinary JavaScript values look like: an object literal written on the spot, a result returned by JSON.parse, an object assembled from a few functions. None of those declared anything. If compatibility required a declaration, every existing JavaScript file would have to be rewritten before TypeScript could type it, and nobody would do that. Structural typing lets TypeScript describe JavaScript as it is written, which is what makes the "superset of JavaScript" claim possible.

§03

Direction: more members can stand in for fewer

The duck test has a direction. Someone with twenty skills can do a job that asks for three. The reverse does not work.

Assignability direction: more members can stand in for fewer, not the other way round
Baristathe value · 3 members
namestring
makeTea() => void
yearsnumber
?
Staffthe target · 1 member
namestring
// ← pick a direction to try
Two types are on the table. Barista has three members, Staff requires one. Use the buttons above to try both assignment directions, and predict which one is allowed.
compat.ts · the same two directions, written out
1type Staff = { name: string };
2
3const barista = {
4 name: "Zhen",
5 makeTea: () => {},
6 years: 3,
7};
8
9// more members ⭢ fewer members: accepted
10const s: Staff = barista; // ✓
11
12// fewer members ⭢ more members: rejected
13const staff = { name: "New hire" };
14// const b: typeof barista = staff;
15// ✕ Type '{ name: string; }' is missing the following
16// properties from type '{ name: string; makeTea:
17// () => void; years: number; }': makeTea, years ts(2739)

The set view: more members, smaller set

One more way to see it, and it is the one that stays reliable: a type is a set of values. { name: string } describes a large set, because every object with a name belongs to it. { name: string; makeTea: () => void; years: number } describes a much smaller set, because more requirements means fewer values qualify.

So "more members" means "more specific", which means "smaller set". Every value in the smaller set is also in the larger one, so a Barista can always be used where a Staff is expected. The reverse is not true. This direction is the same one that unions in chapter 03 and generic constraints in chapter 05 depend on.

Optional member vs a member that may be undefined

These two look similar and are not the same shape. { note?: string } says the key may be missing. { note: string | undefined } says the key must be present, and its value may be undefined. Because the second one requires a member that the first one does not, the first is not assignable to the second.

optional.ts · “may be absent” is not “may be undefined”
1type A = { note?: string }; // the key may be absent
2type B = { note: string | undefined }; // the key must be there
3
4declare const a: A;
5// const b: B = a;
6// ✕ Property 'note' is optional in type 'A' but
7// required in type 'B'. ts(2322)
8
9const ok: B = { note: undefined }; // ✓ present, and undefined
10const fine: A = {}; // ✓ absent is allowed

Members that are functions follow the same shape comparison, but their parameters are checked in the opposite direction from their return types. Chapter 02 covers that in full, including the strictFunctionTypes flag and the exception for method syntax.

§04

The excess property check: object literals are treated differently

§03 just said extra members are fine. Now the compiler appears to say the opposite. This is the point where most beginners get stuck, so this section takes it apart.

Literal at the call site · error
1type Order = {
2 item: string;
3 sweetness?: string; // optional
4};
5
6function makeOrder(o: Order) {}
7
8makeOrder({
9 item: "Boba milk tea",
10 sweetnes: "half sugar",
11});
12// ✕ Object literal may only specify
13// known properties, but 'sweetnes'
14// does not exist in type 'Order'.
15// Did you mean to write
16// 'sweetness'? ts(2561)
Stored in a variable first · accepted
1// the same object, stored first
2const draft = {
3 item: "Boba milk tea",
4 sweetnes: "half sugar",
5};
6
7makeOrder(draft); // ✓ compiles
8
9// but sweetness was never set,
10// so the half sugar is gone

The object is identical in both cases. Written at the call site it is an error. Stored in a variable first it compiles. This is not a bug. It is a separate, stricter check called the excess property check. It runs only on a fresh object literal, which means a literal written directly where a type is expected. It is deliberately not part of the assignability rule, which is exactly why storing the object first makes it disappear. The reason for the design is the difference between these two situations:

Literal at the call site
Written for this one call

This object was written on the spot and handed over immediately. It has no second use. So an unexpected property can only mean one of two things: a typo (sweetnes), or a misunderstanding of the type. Both are bugs, so the compiler reports it. That is how the half sugar on the left was saved.

Stored in a variable
May have another legitimate use

An object held in a variable may be used elsewhere for a perfectly valid reason. It might really be a richer Barista that is also being used as Staff. §03 allows that, so the compiler allows it. The cost is that a misspelled property passes through unnoticed, which is how the half sugar on the right was lost.

Shape matcher: the check performed by startShift(s: Staff)
The value being passedRequired · type Staff
name: "Qiang"stringname: string
makeTea: () => { … }() => voidmakeTea: () => void
salary: 8000number(not required)
startShift({ name: "Qiang", makeTea: () => { … }, salary: 8000 })
The object is written at the call site and passed to startShift as a literal. The members are now checked one by one.
1 / 5

Silencing the check with as does not fix anything

makeOrder({ item: "Boba milk tea", sweetnes: "half sugar" } as Order) does make the error go away. The typo is still there, and the half sugar is still lost. as tells the compiler to stop checking; it does not change the object. The fix is always the same: read the property name in the error message and correct the spelling.

How to read the two error messages

When the extra property looks like a misspelling of a real one, TypeScript names the fix: Object literal may only specify known properties, but 'sweetnes' does not exist in type 'Order'. Did you mean to write 'sweetness'? ts(2561)

When the extra property resembles nothing in the target, there is no suggestion and the code is different: Object literal may only specify known properties, and 'cup' does not exist in type 'Order'. ts(2353)

Both say the same thing: an object literal may only contain properties the target type knows about. Seeing ts(2561) is good news, because the compiler already worked out the correct spelling for you.

§05

When identical shapes are a problem

Two types that mean completely different things can be swapped freely, as long as their members happen to line up.

A pickup order got a courier label

The shop has two types: DeliveryAddress for the delivery platform, and PickupInfo for orders collected in store. Two people defined them separately, and both ended up as a phone number plus a note. One day someone passed a pickup order to the function that prints courier labels. The compiler said nothing, because the members lined up and it had no reason to object. That evening a courier followed the note on the label and went to the shop to collect a delivery that did not exist.

collision.ts · the compiler is right, the difference was never written down
1type DeliveryAddress = { phone: string; note: string };
2type PickupInfo = { phone: string; note: string };
3
4function printShippingLabel(addr: DeliveryAddress) {
5 // print a courier label...
6}
7
8const pickup: PickupInfo = { phone: "138...", note: "less ice, pickup in store" };
9printShippingLabel(pickup);
10// ✓ compiles, but this order is a pickup and needs no label
Structural typing gave the correct answer: the two types have the same members. The problem is that the difference between "delivery" and "pickup" existed only in our heads. It was never written into the shape, and the compiler only reads the shape.

The version you will meet more often is mixed-up identifiers. UserId and PostId are both string, so looking up a user by a post id compiles without a word. There is only one real fix: write the difference into the shape. If only shapes are compared, then make the shapes differ.

branded.ts · branded types, the standard workaround
1type UserId = string & { __brand: "user" };
2type PostId = string & { __brand: "post" };
3
4declare function getUser(id: UserId): void;
5declare const postId: PostId;
6
7// getUser(postId);
8// ✕ Argument of type 'PostId' is not assignable to
9// parameter of type 'UserId'. ... Type '"post"' is
10// not assignable to type '"user"'. ts(2345)
11
12// creating one always costs an assertion
13const uid = "u_42" as UserId;
14getUser(uid); // ✓
Intersecting each type with an object type that carries a marker member makes the two shapes different, so the two strings are no longer interchangeable. __brand exists only at compile time and costs nothing at run time. Be honest about the price: a plain string is not assignable to UserId, so creating one always needs an assertion. The usual discipline is to write a single toUserId(s: string) function that performs that assertion, call it only where data enters the system, and never write as UserId anywhere else. Lab 3 in §06 walks through it.

One more trap: the empty object type {}

Take the duck test to its limit. {} requires zero members, and almost every value satisfies a list of zero requirements. 42, "tea", true, a function, an array, and any object are all assignable to {}. Only null and undefined are rejected, and only because strictNullChecks is on. So write object when you mean any object, and unknown when you mean any value at all and you will narrow it before use. {} looks like a requirement and is not one.

§06

Practice

Four tasks, all of which run in the TypeScript Playground (typescriptlang.org/play): trigger both faces of the excess property check, then reproduce a same-shape accident and block it.

§07

Chapter quiz

Eight questions. After this chapter you should be able to answer “how does TypeScript decide that two types are compatible” from shapes, to sets, to the special treatment of object literals.

QUESTION 01 / 8

When TypeScript decides whether two types are compatible, what does it compare?

QUESTION 02 / 8

Given interface A { x: number } and interface B { x: number }, and const a: A = { x: 1 }, what happens on const b: B = a?

QUESTION 03 / 8

type Staff = { name: string }, and the variable barista has type { name: string; makeTea: () => void }. Which assignment compiles?

QUESTION 04 / 8

makeOrder({ item: "Milk tea", cup: "large" }) fails because of the extra cup, but const d = { item: "Milk tea", cup: "large" } followed by makeOrder(d) compiles. Why?

QUESTION 05 / 8

Which of these statements about structural typing are true? (multiple answers)

QUESTION 06 / 8

UserId and PostId are both declared as string, so passing a post id where a user id is expected compiles. What is the right fix?

QUESTION 07 / 8

A type system that decides compatibility by shape rather than by name is called ________ typing (answer in English).

QUESTION 08 / 8

You want a parameter type that means "any object is fine, but not a primitive value". Which one do you write?

What to take away from this chapter
  • TypeScript uses structural typing: compatibility depends on the members, not on the name. The name is a label; the member list is the type. Classes are compared the same way.
  • Direction matters: a type with more members is assignable to one with fewer, never the other way round. The set view is the easiest way to remember it. More requirements means a smaller set, and a smaller set is contained in the larger one.
  • The excess property check applies only to a fresh object literal: written at the call site it is an error, stored in a variable first it is allowed. It is an extra check layered on top of assignability, aimed at typos, not a general rule.
  • Silencing that error with as hides the typo instead of fixing it. Read the property name in the message and correct the spelling. ts(2561) even tells you the correct name; ts(2353) is the same error without a suggestion.
  • Identical shapes are interchangeable even when they mean different things. For accidents like UserId mixed with PostId, use a branded type to write the difference into the shape. And note that { a?: number } is not { a: number | undefined }, and that {} requires nothing at all: write object when you mean any object.