Skip to content
TR

Phase 2 · Type system core · Lesson 2.4

Beginner

Structural typing and intersections

TypeScript compares types by shape, not by name. Learn what that means for assignability, why excess property checks only fire on fresh object literals, how intersections combine types, and when functions are assignable.

18 min

If you come from Java or C#, this one surprises you: in TypeScript, two types with completely different names are interchangeable if they have the same shape. An object never has to say "I implement Point"; it just has to look like a Point. That design fits JavaScript, where objects are created ad hoc everywhere, but it has consequences: some checks you'd expect don't happen, and one check you might not expect does.

Structural vs nominal typing

In a nominal type system (Java, C#, Swift), compatibility is about names and declarations: a Celsius is not a Fahrenheit, even if both wrap a double, unless one is declared to extend the other.

TypeScript is structural: compatibility is about members. If a value has every property a type requires, with compatible types, it's assignable.

type Point = { x: number; y: number };
 
class Vector {
  constructor(public x: number, public y: number) {}
}
 
function plot(p: Point) {
  return `(${p.x}, ${p.y})`;
}
 
plot({ x: 1, y: 2 });   // an object literal: fine
plot(new Vector(3, 4)); // a class that never mentions Point: also fine

Vector never declares any relation to Point. It doesn't need to. Structurally, it has x: number and y: number, so it is a Point as far as the checker is concerned.

Assignability: "at least these members"

The core rule: S is assignable to T if S has at least every required member of T, and each member's type is assignable. Extra members are fine.

type Point = { x: number; y: number };
type Point3D = { x: number; y: number; z: number };
 
const p3: Point3D = { x: 1, y: 2, z: 3 };
const p2: Point = p3;     // fine: Point3D has everything Point needs
 
// @ts-expect-error -- Property 'z' is missing in type 'Point' but required in type 'Point3D'.
const back: Point3D = p2;

This is why people say a type describes a minimum, not an exact shape. A Point is "anything with at least a numeric x and y". There's no built-in "exact object type" in TypeScript.

That has a practical consequence worth remembering: Object.keys(p2) is typed as string[], not ("x" | "y")[], precisely because p2 might be a Point3D at runtime with extra keys.

Where TypeScript becomes nominal-ish

Classes with private, protected or #private members break pure structural typing. Two classes with identically declared private fields are not compatible, because a private member is tied to the declaration it came from:

class UserId {
  private readonly value: string;
  constructor(value: string) { this.value = value; }
}
 
class OrderId {
  private readonly value: string;
  constructor(value: string) { this.value = value; }
}
 
// @ts-expect-error -- Types have separate declarations of a private property 'value'.
const id: UserId = new OrderId("o-1");

This, plus the branded type trick (type UserId = string & { readonly __brand: "UserId" }), is how you get nominal-style safety when you really need "don't mix these two IDs". Branding gets its own lesson later.

Quick check

Which assignment is a type error?

type Named = { name: string };
const person = { name: "Ada", age: 36 };
class Pet { name = "Rex"; legs = 4; }

Excess property checks: the exception

After "extra members are fine", this looks like a contradiction:

type Point = { x: number; y: number };
 
// @ts-expect-error -- Object literal may only specify known properties, and 'z' does not exist in type 'Point'.
const p: Point = { x: 1, y: 2, z: 3 };
 
const temp = { x: 1, y: 2, z: 3 };
const q: Point = temp; // no error!

Same value, same target type, different result. The rule: excess property checking applies only to "fresh" object literals, meaning a literal written directly where a specific type is expected (an annotated variable, a function argument, a return value). Once the literal has been stored in a variable, it's no longer fresh, and ordinary structural assignability applies.

Why? It's a typo detector. If you write an object literal right at the point where a Point is expected, an extra property is almost certainly a mistake: a misspelled optional field, or a field that belongs somewhere else. If a value came from elsewhere, it's normal for it to carry more data than this particular function cares about.

The classic bug it catches:

type Options = { color?: string; width?: number };
function draw(options: Options) {}
 
// @ts-expect-error -- Object literal may only specify known properties, but 'colour' does not exist in type 'Options'. Did you mean to write 'color'?
draw({ colour: "red" });

Without the check, colour would be silently ignored, because every field in Options is optional and { colour: "red" } would otherwise match structurally.

Ways the check is bypassed

All of these skip excess property checking, some legitimately, some dangerously:

  • Assigning through an intermediate variable (as with temp above).
  • A type assertion: { x: 1, z: 3 } as Point.
  • An index signature in the target, e.g. { x: number; [key: string]: unknown }, which explicitly allows extra keys.

A type whose properties are all optional is called a weak type. TypeScript adds one more check for them, and this one applies even through a variable: the source must share at least one property with it.

type Options = { color?: string; width?: number };
 
const settings = { colour: "red" };
// @ts-expect-error -- Type '{ colour: string; }' has no properties in common with type 'Options'.
const opts: Options = settings;

Quick check

How many of these lines are type errors?

type User = { name: string };
function save(user: User) {}
const extra = { name: "Ada", admin: true };
save(extra);
save({ name: "Ada", admin: true });
save({ name: "Ada", admin: true } as User);

Intersection types: &

An intersection A & B is a type that is both A and B at once, so it has all the members of both.

type HasId = { id: string };
type HasTimestamps = { createdAt: Date; updatedAt: Date };
 
type Entity = HasId & HasTimestamps;
 
const post: Entity = {
  id: "p-1",
  createdAt: new Date(),
  updatedAt: new Date(),
};

It's often used to add fields to an existing type, or to mix reusable pieces together:

type WithOwner<T> = T & { ownerId: string };
 
type Note = { text: string };
const note: WithOwner<Note> = { text: "hi", ownerId: "u-1" };

Note the naming, which trips people up: for object types, the union A | B has fewer safe members (only shared ones), while the intersection A & B has more (all of them). The names refer to the sets of values: A & B values are in both sets, so they satisfy both descriptions, so they have both sets of members.

Conflicting properties produce never

What if both sides declare the same property with incompatible types?

type A = { value: string };
type B = { value: number };
type AB = A & B;
 
type V = AB["value"];
//   ^? type V = never

The property becomes string & number, which no value can be, so it's never. The intersection type itself still exists, but you can never create a value of it. There's no error at the point where you wrote A & B; you only find out when you try to use it. That's the main danger of intersections.

When the conflicting property is a literal type (a discriminant), TypeScript goes further and reduces the whole intersection to never:

type Cat = { kind: "cat"; meows: boolean };
type Dog = { kind: "dog"; barks: boolean };
 
type CatDog = Cat & Dog;
//   ^? type CatDog = never

And for primitives it's immediate: string & number is never. Compatible properties, on the other hand, intersect usefully: { a: string | number } & { a: string | boolean } gives a: string.

Intersections vs extends

You can build Entity with an interface too:

interface HasId { id: string }
interface Entity extends HasId {
  createdAt: Date;
}

They look interchangeable, but they behave differently when things conflict:

interface Base { value: string }
 
// @ts-expect-error -- Interface 'Broken' incorrectly extends interface 'Base'. Types of property 'value' are incompatible.
interface Broken extends Base { value: number }
 
type Silent = Base & { value: number }; // no error here; value is silently never
interface X extends Atype X = A & B
Conflicting propertyError at the declarationSilently becomes never
Narrowing a property (value: "a" extends value: string)AllowedWorks (result is "a")
Works with unions, primitives, mapped typesNo, object types onlyYes, any types
Compiler performanceBetter: relationships are cachedRecomputed; can be slow when deep

The TypeScript team's own advice: prefer interface ... extends when you're composing object types, because conflicts are reported and checking is faster. Use & when you need to combine things interfaces can't express, like a generic T & { ownerId: string } or intersecting with a union.

Function assignability basics

Structural typing applies to functions too. When is one function type assignable to another?

Fewer parameters are fine

A function that ignores some of the arguments it's given is perfectly safe. JavaScript simply drops extra arguments.

type Callback = (item: string, index: number) => void;
 
const logItem: Callback = (item) => console.log(item);   // fine: ignores index
const noArgs: Callback = () => console.log("called");    // fine
 
// @ts-expect-error -- Target signature provides too few arguments. Expected 3 or more, but got 2.
const tooMany: Callback = (item: string, index: number, extra: boolean) => {};

This is why ["a", "b"].forEach((item) => ...) works even though forEach passes three arguments. Needing more parameters than the caller provides is the error, because they'd be undefined.

Parameter types must accept what the caller passes

Under strictFunctionTypes (part of strict), a function-typed property only accepts functions whose parameters can handle everything the caller might pass:

type Handler = { handle: (value: string | number) => void };
 
// @ts-expect-error -- Type '(value: string) => void' is not assignable to type '(value: string | number) => void'.
const h: Handler = { handle: (value: string) => console.log(value.toUpperCase()) };

The caller is allowed to pass a number, and toUpperCase would crash. (For method syntax, handle(value: string | number): void, TypeScript is more lenient and allows this for historical reasons: methods are checked bivariantly. The variance lesson covers why.)

Return types: void is special

A return type of void in a function type means "the caller won't use the result", so a function that returns something is still assignable:

const results: number[] = [];
const push: (n: number) => void = (n) => results.push(n); // push returns a number: fine
 
// But a function *declaration* with a void return type can't return a value:
function log(message: string): void {
  console.log(message);
}

That's what lets you write arr.forEach((x) => results.push(x)), even though push returns the new length.

{} vs object vs unknown

These three show up in interviews because they look similar and mean very different things. Structural typing explains {}: it's an object type with no required members, so almost anything satisfies it.

const a: unknown = null;       // unknown: literally anything
 
const b: {} = "text";          // {}: anything except null and undefined,
const c: {} = 42;              //     primitives included!
// @ts-expect-error -- Type 'null' is not assignable to type '{}'.
const d: {} = null;
 
const e: object = { x: 1 };    // object: non-primitive values only
const f: object = [1, 2];
// @ts-expect-error -- Type 'number' is not assignable to type 'object'.
const g: object = 42;
 
console.log(a, b, c, d, e, f, g);

▶ Try it in the TypeScript Playground

TypeAcceptsCan you use its members?
unknownEverything, including null and undefinedNo, narrow first
{}Everything except null and undefinedOnly Object.prototype members like toString
objectOnly non-primitives: objects, arrays, functionsOnly Object.prototype members

In practice: use unknown for "I don't know what this is yet", object for "some non-primitive", and avoid {} (it doesn't mean "empty object"). If you really mean "an object with no properties", use Record<string, never>. And since TypeScript 4.8, unknown behaves like {} | null | undefined when you narrow it: if (x != null) turns an unknown into {}.

Spot the error

This code is meant to reject unknown config keys, but a typo slipped into production anyway. Why didn't TypeScript catch it?

type Config = { retries?: number; timeoutMs?: number };
 
function connect(config: Config) {}
 
const defaults = { retries: 3, timeoutMS: 5000 };
connect(defaults);
Show the answer

timeoutMS has the wrong casing, but defaults is not a fresh object literal at the call site, so the excess property check doesn't run. And the weak-type check passes because defaults shares retries with Config. The typo is silently ignored and the timeout falls back to the default.

Fix it by typing the object where it's created, so the literal is checked there:

type Config = { retries?: number; timeoutMs?: number };
 
function connect(config: Config) {}
 
const defaults: Config = { retries: 3, timeoutMs: 5000 };
connect(defaults);

Or pass the literal directly: connect({ retries: 3, timeoutMs: 5000 }). (satisfies Config, covered later, also works and keeps the narrower inferred type.)

Recap

  • TypeScript is structural: a value fits a type if it has the required members, whatever it's called. Extra members are allowed.
  • Classes with private/#private members, and branded types, are the ways to get nominal-style behaviour.
  • Excess property checks only apply to fresh object literals written where a type is expected; going through a variable, an assertion or an index signature skips them.
  • Weak types (all-optional) additionally require at least one shared property, even through a variable.
  • A & B has the members of both. Conflicting property types become never; conflicting literal discriminants make the whole type never.
  • Prefer interface extends for object composition: it reports conflicts and checks faster. Use & for generics and non-object types.
  • Functions: fewer parameters are fine; parameter types must accept everything the caller passes; a void function type accepts functions that return values.
  • unknown accepts everything, {} everything except null/undefined, object only non-primitives.

Interview cards

1 / 8