Be mindful of TypeScript’s Excess Property Checking

TypeScript is a structural type system, also called “duck typing”: if it walks like a duck, and it quacks like a duck, then it must be a duck. Here’s the definition from the Type Compatibility docs:

The basic rule for TypeScript’s structural type system is that x is compatible with y if y has at least the same members as x.

As long as the right side of the assignment has at least the properties of the target type, we’re all good:

type Duck = {
  name: string;
  quack: () => void;
};

let realDuck: Duck = { name: "Scrooge", quack: () => console.log("Quack!") };
let sneakyDog = {
  name: "Iggy",
  quack: () => console.log("Quack!"),
  woof: () => console.log("Woof!"),
}; // This object has an extra "woof" method...

realDuck = sneakyDog; // ... but that's OK, because it has all the expected properties and methods of type "Duck" (it can quack!)

The code above is perfectly OK. The type of sneakyDog is compatible with the type of realDuck (in this case, Duck) because the type of sneakyDog has at least the properties of the target type Duck.

Excess Property Checking

This structural typing behavior is the default, but there are cases when TypeScript behaves in a more strict manner, and that’s when it triggers excess property checking. Let’s see when this happens.

Using the code above as the basis, if we just omit the intermediate sneakyDog variable, and assign an object literal to realDuck of type Duck, we get a type error:

type Duck = {
  name: string;
  quack: () => void;
};

let realDuck: Duck = { name: "Scrooge", quack: () => console.log("Quack!") };

realDuck = {
  name: "Iggy",
  quack: () => console.log("Quack!"),
  woof: () => console.log("Woof!"),
};
// Error: Type '{ name: string; quack: () => void; woof: () => void; }' is not assignable to type 'Duck'.
//  Object literal may only specify known properties, and 'woof' does not exist in type 'Duck'.(2322)

TypeScript has now decided to check for excess properties during the assignment.

We’ll get the same error when trying to pass an object literal as an argument:

type Duck = {
  name: string;
  quack: () => void;
};

let realDuck: Duck = { name: "Scrooge", quack: () => console.log("Quack!") };

function ProcessDuck(duck: Duck) {
  console.log(`Duck name: ${duck.name}`);
  duck.quack();
}

ProcessDuck({
  name: "Iggy",
  quack: () => console.log("Quack!"),
  woof: () => console.log("Woof!"),
});
// Error: Argument of type '{ name: string; quack: () => void; woof: () => void; }' is not assignable to parameter of type 'Duck'.
//  Object literal may only specify known properties, and 'woof' does not exist in type 'Duck'.(2345)

It didn’t perform this check when there was an intermediate variable in between, because object literals represent a special case when excess property checking kicks in, specifically the two cases shown above (assignment & argument passing).

Why does this feature exist?

There’s a very good reason this exists, and it’s explained nicely in TypeScript’s older docs, see Excess Property Checks. I don’t know why this isn’t covered in the new documentation (it’s not even in the Type Compatibility page).

This feature exists to protect us from errors that could result from entering wrong property names:

type Shape = {
  color?: string;
  type: "Circle" | "Square" | "Triangle";
};

function RenderShape(shape: Shape) {
  // Do the rendering
}

RenderShape({ type: "Circle", colour: "red" });
// Argument of type '{ type: "Circle"; colour: string; }' is not assignable to parameter of type 'Shape'.
//  Object literal may only specify known properties, but 'colour' does not exist in type 'Shape'. Did you mean to write 'color'?(2345)

Notice how we accidentally used colour as the property name in the object literal argument, whereas the real property name should be color. If we were to rely solely on structural typing, this assignment would be deemed as structurally compatible and result in unintended runtime behavior.

TypeScript is essentially taking the stance that, in these situations, it is highly unlikely for you to want to pass in excess properties, and therefore it will be more strict.

Conclusion

This behavior has always made me question my TS knowledge when I ran into it and seemed inconsistent, but it’s nice to finally understand when it happens and why it exists.

There’s some discussion on Github related to this, proposing an Exact<T> syntax for exact types, see https://github.com/microsoft/TypeScript/issues/12936.