---
title: 'Structural Type Compatibility'
source: 'https://academia.sh/en/courses/typescript/structural-type-compatibility'
course: TypeScript
language: en
updated: '2026-08-17T18:09:53+00:00'
license: 'CC BY-SA 4.0'
---

# Structural Type Compatibility

The rules of duck typing, excess property checking on object literals, the direction rule for function parameters, and ways to break structural compatibility.

In none of this topic's examples did a value write the name of the type it fit. Object
literals were accepted into interfaces, functions into function types, only because
their **shapes** matched.

This is TypeScript's fundamental compatibility rule. **Structural typing** — popularly
known as **duck typing** — determines whether a value fits a type by looking at the
members it carries, not the name it declares. Its opposite is **nominal typing**: type
compatibility holds only when explicitly declared.

This lesson takes up structural typing's rules, the problems it causes, and how to
break it when necessary.

## The Basic Rule

A type is compatible if it carries every member the target type requires. Carrying
extra members does not break compatibility:

```typescript
interface Measurement {
  id: string;
  value: number;
}

const matching = { id: "s-01", value: 21.4, location: "boiler-2" };
const viaVariable: Measurement = matching;
console.log(viaVariable.id);

const direct: Measurement = { id: "s-02", value: 22.1, location: "boiler-3" };
```

```text
k1.ts(10,56): error TS2353: Object literal may only specify known properties, and 'location' does not exist in type 'Measurement'.
```

Line 7 is valid: the `matching` object carries the two fields `Measurement`
requires; the third field is extra and gets ignored. Line 10, though, is rejected
when an object of the same shape is written directly.

The difference is the **excess property check**. The compiler treats unknown fields
as an error in **fresh object literals** assigned directly to a type. The reasoning
is practical: an extra field in a literal written directly is almost always a typo or
a misunderstood contract. In an object coming through a variable, an extra field is
normal — the object may be serving another purpose too.

The check's narrow scope is deliberate and does not provide full protection.
Assigning to an intermediate variable bypasses the check; this is the rule's scope,
not a loophole in it.

## The Name Plays No Role

Structural typing's most striking consequence is that two unrelated types can be
compatible:

```typescript
interface Measurement {
  id: string;
  value: number;
}

interface Product {
  id: string;
  value: number;
}

const measurement: Measurement = { id: "s-01", value: 21.4 };
const product: Product = measurement;
console.log(product.value);
```

This file compiles without error. `Measurement` and `Product` are different
concepts, their `value` fields measure different things; as far as the type system is
concerned, they are the same type.

This behavior is not a flaw, it is a design decision. TypeScript is layered on top of
existing JavaScript; in JavaScript, objects travel by the properties they own, not by
declared types. If a library can be handed "an object carrying these fields," the type
system has to recognize the same flexibility. Structural typing is also why the
previous lesson could implement an interface without any class writing its name.

The cost is that types with different meanings can get mixed up. A tool against this
will be built by the end of this course.

## The Direction Rule for Functions

In function types, compatibility works in the **reverse** direction for parameters:

```typescript
interface Measurement {
  id: string;
  value: number;
}

interface LocatedMeasurement extends Measurement {
  location: string;
}

type Handler = (measurement: Measurement) => void;

const byLocation = (measurement: LocatedMeasurement): void => console.log(measurement.location);
const handler: Handler = byLocation;

handler({ id: "s-01", value: 21.4 });
```

```text
k4.ts(13,7): error TS2322: Type '(measurement: LocatedMeasurement) => void' is not assignable to type 'Handler'.
  Types of parameters 'measurement' and 'measurement' are incompatible.
    Property 'location' is missing in type 'Measurement' but required in type 'LocatedMeasurement'.
```

The reasoning shows up on line 15: the `handler` type promises a function taking
`Measurement`; the caller sends a record with no `location` field; `byLocation`
accesses that field. Had the assignment been accepted, the program would operate on
`undefined` at runtime.

The rule can be summed up: a function can be assigned to a type that accepts a
**wider** parameter; a function requiring a **narrower** parameter cannot. For the
return type, the direction is straight — a narrower return type fits where a wider
one is expected.

This rule applies only to function types declared with arrow syntax. For members
declared with **method syntax**, the compiler allows both directions:

```typescript
interface Measurement {
  id: string;
  value: number;
}

interface LocatedMeasurement extends Measurement {
  location: string;
}

interface HandlerMethod {
  handle(measurement: Measurement): void;
}

const obj: HandlerMethod = {
  handle(measurement: LocatedMeasurement): void {
    console.log(measurement.location);
  },
};

obj.handle({ id: "s-01", value: 21.4 });
```

This file **passes** type checking and prints `undefined` when run.

This is a gap the type system deliberately leaves open. The reason is how array
methods are typed: `Array<T>`'s methods are written with method syntax, and applying
the strict rule would reject many commonly used patterns. It is a concrete example of
the "not fully trustworthy" situation mentioned in the first lesson.

Practical takeaway: declaring callback types with **arrow syntax** is safer.

## Breaking Structural Compatibility

Compatibility might be broken deliberately for two reasons: separating same-shaped
types that carry different meanings, and recording at the type level that a value has
passed a specific validation.

Having a `private` or `protected` member in a class breaks compatibility on its own:

```typescript
class MeasurementLog {
  private records: number[] = [];
}

class ProductLog {
  private records: number[] = [];
}

const log: MeasurementLog = new ProductLog();
console.log(log);
```

```text
k3.ts(9,7): error TS2322: Type 'ProductLog' is not assignable to type 'MeasurementLog'.
  Types have separate declarations of a private property 'records'.
```

The two fields carry the same name and the same type; they're incompatible anyway,
because private members are only considered compatible when they come from **the same
declaration**. This gives classes behavior close to nominal typing.

The corresponding technique for object types is the **branded type**: a field that
does not exist at runtime is added to the type.

```typescript
declare const brand: unique symbol;

type SensorId = string & { readonly [brand]: "sensor" };

function sensorId(raw: string): SensorId {
  if (!/^s-\d{2}$/.test(raw)) {
    throw new Error(`invalid sensor id: ${raw}`);
  }
  return raw as SensorId;
}

function readMeasurement(id: SensorId): string {
  return `${id} read`;
}

console.log(readMeasurement(sensorId("s-01")));

const text: string = sensorId("s-02");
console.log(text.toUpperCase());
```

Output:

```text
s-01 read
S-02
```

If the line `readMeasurement("s-03");` is added to the end of the file, after a blank
line:

```text
marka2.ts(21,17): error TS2345: Argument of type 'string' is not assignable to parameter of type 'SensorId'.
  Type 'string' is not assignable to type '{ readonly [brand]: "sensor"; }'.
```

The structure has three parts. `declare const brand: unique symbol` produces a unique
type-level key — since it is written with `declare`, it has no runtime counterpart.
`SensorId` is the intersection of a string with an object carrying that key; no real
string carries this field, so entry into the type is only possible through a type
assertion. The `sensorId` function makes that assertion in exactly one place, **after**
validation.

The result is a proof carried in the type system: a value of type `SensorId` means it
has passed the format check. That the `text` declaration is valid is also part of the
design — a branded type is still a string, and string operations can be used on it;
the reverse direction is closed.

This pattern follows the rule set in the first lesson: validation happens at runtime,
and the type system only records the result.

## Summary

- In structural typing, compatibility is determined by looking at carried members,
  not the declared name; two unrelated types with the same shape are compatible.
- Excess property checking applies only to object literals written directly; it does
  not apply to objects coming through an intermediate variable.
- Compatibility for function parameters works in reverse: a function accepting a
  wider parameter can be assigned to a type declaring a narrower one.
- This rule is relaxed for members declared with method syntax, leaving a gap that
  can produce a runtime error.
- Private members in classes break compatibility; for object types, a branded type
  is used, and this pattern records at the type level that a validation has taken
  place.

## Next Step

Every type in this topic was specific and fixed: a log holding `Measurement` records,
a filter taking `Measurement`. But a log structure needs to be independent of its
record type — the same code should be able to collect measurements, warnings, or
events. The next topic introduces type parameters and builds that independence without
losing type safety.
