Intersection and Structural Typing: How TypeScript Looks at Types
We look at structural typing, where TypeScript judges types by shape rather than name, the assignability rule, how it relates to excess property checking, and intersections that combine types with &.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
In the previous post we talked about parameter and return types of functions. Now let's step back and ask a more basic question: when you pass an object to a function, what exactly does TypeScript check? Look at this code:
type Point = { x: number; y: number };
function printPoint(p: Point) {
console.log(`${p.x}, ${p.y}`);
}
const place = { x: 10, y: 20, label: "Home" };
printPoint(place);
printPoint({ x: 10, y: 20, label: "Home" });
Both calls seem to send the same value. Does this code compile? If it fails, on which line? Write your guess down; we'll answer once we've seen how TypeScript looks at types.
Structural Typing: Looking at Shape, Not Name
If you come from languages like Java or C#, two types are usually compatible only if they share a name or one explicitly derives from the other. That's called nominal typing. TypeScript works differently: it decides whether a value fits a type by looking at the value's shape, meaning which properties it has and what their types are. That's called structural typing. It's the compile-time version of what the JavaScript world calls "duck typing": if it walks like a duck and quacks like a duck, it's a duck.
class Cat {
nickname = "Tom";
speak() {
return "Meow";
}
}
class Robot {
nickname = "R2";
speak() {
return "Beep";
}
}
const pet: Cat = new Robot(); // compiles: the shapes match
console.log(pet.speak()); // Beep
There's no extends relationship between Robot and Cat, but both have a nickname string and a speak method that returns a string. For TypeScript that's enough. This fits the spirit of JavaScript: in the JavaScript series, too, a function cared not about which class an object came from but about whether the method it calls actually exists on it.
An Intuition for Assignability: Extra Is Fine, Missing Is Not
The word you'll see most often in TypeScript's error messages is "assignable." The core of the rule for assigning a value to a type is this: every property the target type asks for must exist on the source, with compatible types. Extra properties on the source are not a problem.
type Named = { name: string };
type Person = { name: string; age: number };
const person: Person = { name: "Ada", age: 36 };
const named: Named = person; // compiles: Person has everything Named asks for
// Error: Property 'age' is missing in type 'Named' but required in type 'Person'.
const back: Person = named;
Think of it as a job posting. If the posting asks for "someone who speaks English," a candidate who speaks English and German qualifies too; extra skills don't disqualify anyone. But if the posting asks for two languages, a candidate with one is out. Person can apply for the Named posting; the reverse doesn't work. The error code is TS2741, and it names the missing property explicitly.
The check works for nested values as well. If a property's type is itself an object, TypeScript asks the same question again at that level. Optional properties, on the other hand, may be missing from the source altogether; the ? we saw in the object types post means "this item isn't required for the posting."
How It Relates to Excess Property Checking
Now the answer to the opening question: the first call compiles, the second doesn't. tsc says:
error TS2353: Object literal may only specify known properties, and 'label' does not exist in type 'Point'.
We just set the rule "extra is fine," so why does the second call fail? Because the excess property check we met in the object types post applies only to fresh object literals, meaning objects written right there in place and matched against a type. The reasoning goes like this: when you hand the literal { x: 10, y: 20, label: "Home" } straight to printPoint, there is no other way to reach label. Since the property can't be used inside the function either, it's most likely a typo, or you're using the wrong type. In that case TypeScript deliberately tightens the structural rule.
With the place variable things are different. That object has a name, and you might be using label somewhere else in your code. Here TypeScript goes back to the normal assignability rule: place has x and y, the rest is extra. So the excess property check isn't an exception to structural typing; it's an extra seatbelt added to literals to catch typos.
Intersection: Combining Types with &
Once structural typing clicks, intersections feel very natural. A & B means "values that have everything both A and B ask for." It's ideal for pulling shared fields out into separate types and combining them when needed:
type Timestamps = {
createdAt: Date;
updatedAt: Date;
};
type Product = {
id: string;
title: string;
price: number;
};
type StoredProduct = Product & Timestamps;
function save(product: Product): StoredProduct {
const now = new Date();
return { ...product, createdAt: now, updatedAt: now };
}
const saved = save({ id: "p1", title: "Mechanical Keyboard", price: 120 });
console.log(saved.title, saved.createdAt instanceof Date); // Mechanical Keyboard true
StoredProduct requires all five properties. Delete updatedAt from the return value and TypeScript reports the missing property immediately. In the type alias and interface post we compared extends with intersections; what matters here is that an intersection can combine any two types on the spot, even anonymous object types.
Union and Intersection: Why the Names Feel Backwards
This is where most people get confused on first contact. Intersection means "the overlap," yet the properties grow. Union means "the combination," yet the properties you can use shrink. The key is to think of types not as property lists but as sets of values.
The Product type is the set of all values that have what Product asks for. Product & Timestamps is the intersection of two sets: values that satisfy both the product requirement and the timestamp requirement. More requirements make the set smaller, but you know more about every value you hold. Product | Timestamps is the union of two sets: the set grows, but since you don't know which one you're holding, you can only safely use what they share. That's exactly why, in the narrowing post, we had to bother with checks like typeof and in on unions.
Conflicting Properties and never
What happens if two types define the same property with different types?
type WithStringId = { id: string };
type WithNumberId = { id: number };
type Broken = WithStringId & WithNumberId;
// Error: Type 'string' is not assignable to type 'never'.
const item: Broken = { id: "1" };
The intersection logic holds here too: id must be both a string and a number. Since no such value can exist, the type of id becomes string & number, which is never. TypeScript doesn't reject the definition; the problem only surfaces when you try to assign a value to that type. So use intersections mainly to combine pieces that don't touch each other.
Common Pitfalls
The first pitfall is what "extra is fine" means at runtime. Types are erased; extra properties are not:
type PublicUser = { name: string };
function toJson(user: PublicUser) {
return JSON.stringify(user);
}
const account = { name: "Ada", password: "secret123" };
console.log(toJson(account)); // {"name":"Ada","password":"secret123"}
The parameter of toJson only sees name, but the real object also has password, and it goes into the JSON as is. A parameter type is not a filter. If you want to narrow down the data that goes to an API or a log, you have to build a new object by hand: { name: user.name }.
The second pitfall is the {} type. It looks like an empty object, but under the structural rule it means "a type that asks for no properties." Almost every value except null and undefined meets that requirement; const value: {} = 42; compiles without errors. If you mean "any object," object or the Record we pointed to earlier are the better tools.
The third pitfall is trusting the excess property check too much. It catches a typo you make in a literal, but not when the same object was put in a variable first. Combined with optional properties, this turns into a sneaky bug:
type UserSettings = { theme: string; fontSize?: number };
const settings = { theme: "dark", fontsize: 16 };
const applied: UserSettings = settings; // compiles: theme exists, fontSize is optional
console.log(applied.fontSize); // undefined
Because fontsize is written in lowercase, TypeScript treats it as extra; the only required property, theme, is there, and fontSize is optional anyway. The result: the compiler is silent and the setting is never applied. That's why writing important configuration objects in place, as literals, together with their type, is a good habit.
A Small Experiment
★☆☆ Predict whether it compiles: const p3d = { x: 1, y: 2, z: 3 }; followed by printPoint(p3d);. And what if you write the same object directly as printPoint({ x: 1, y: 2, z: 3 })? Explain both results with the two rules from this post.
★★☆ Define type Discounted = Product & { price: string };. What do you think the type of the price property in Discounted will be? Check your guess by hovering over it in your editor.
★★☆ Write two types named WithId (id: string) and Draft (title: string, body: string). Build a Post type with an intersection and trigger the error with a literal that leaves out the WithId part.
★★★ Rewrite the toJson example so that extra fields like password can never end up in the JSON. Hint: serialize a new object you build yourself, not the object that came in through the parameter.
In this post we always left the decision about types to TypeScript: it looked at the shape, and we accepted the result. Sometimes, though, we believe we know more about a value than the compiler does, and we tell it so. In the next post we'll talk about type assertions with as, the ! operator, satisfies and as const, and learn to tell which of them protects you and which merely silences the compiler.