Skip to content
academia.sh

Lesson 11 / 23

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.

Contents

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:

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" };
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:

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:

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 });
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:

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:

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

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

const log: MeasurementLog = new ProductLog();
console.log(log);
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.

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:

s-01 read
S-02

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

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close