Structural assignability is the rule
TypeScript's type system asks whether a value has at least the members the target requires. It does not ask whether the value's type was declared with a relationship to the target, and it does not ask whether the value has members the target has never heard of. A type with more properties is assignable to a type with fewer, because everything the target promises is present. That is the whole model, and it is deliberate: it lets you type an existing JavaScript shape without rewriting it, and it lets a function accept anything that satisfies its requirements.
So the honest answer to the question is "it does not complain, because the object is one of those". The interesting part is the single carve-out.
Excess property checking applies to fresh literals only
When you write an object literal directly in the position where a typed value is expected, TypeScript treats that literal's type as fresh and applies a stricter rule: it may only specify known properties. The reasoning is intent. If you typed the properties out at the assignment site and one of them is not in the target type, you almost certainly made a mistake — a typo, or a property you meant to add to the interface. Freshness is lost as soon as the value is assigned to something, so the check happens once, at the literal, and never again.
interface PublicUser { id: string; email: string }
const row = { id: 'u_1', email: 'a@b.com', passwordHash: '$2b$10$...' };
const a: PublicUser = { id: 'u_1', email: 'a@b.com', passwordHash: '$2b$10$...' };
const b: PublicUser = row;
res.json(b);
Trace the two lines with the same three keys. The first fails to compile with an error naming passwordHash. The second compiles, and b is row — the annotation narrowed what you may read through that binding, not what the object contains. res.json does not consult types; it stringifies whatever properties exist. The static type says two fields and the wire carries three.
How a password hash reaches a client
That is the failure worth being able to describe, because it is a data leak produced by code that type-checks and reviews cleanly. A repository returns a row type with every column. A handler declares its return type as the public DTO. Structural typing accepts the row, so nothing objects, and every column the row happens to carry is serialised: the hash, the internal tenant identifier, the soft-delete flag, the raw provider payload you stored for debugging. Adding a column to the table silently widens the API response, and no test that asserts on the fields it expects will notice a field it does not.
A return-type annotation is therefore not a redaction mechanism, and this is the point most candidates miss. To narrow the object you have to build a new one, which means an explicit projection — pick the fields, or map through a function that constructs the DTO literal — so that the fresh-literal check is doing work for you and a missing field is a compile error while an extra field is impossible. A schema-driven serialiser that strips unknown keys at the response boundary achieves the same thing at runtime, and it survives the case where the object came from somewhere your types did not describe.
The other things that discard the check
Any of these makes the literal non-fresh, so the extra property passes: assigning it to an intermediate variable first, spreading it into another object, asserting it as the target type, or passing it through a generic that infers a wider type. None of these are bugs in the compiler and all of them are common in ordinary code, which is why relying on excess property checking as a safety net does not scale.
The narrower and better-behaved tool is satisfies, added in TypeScript 4.9. Where an annotation widens the binding to the declared type, satisfies checks the value against the type and leaves the value's own inferred type intact. That gets you the excess property error and keeps literal types for the keys you did declare, which is exactly what you want for configuration objects and route tables.
The typo you only catch by accident
One consequence is worth stating because it explains why the check exists at all. Given a target with only optional properties, a completely wrong object is structurally assignable — an object with none of them satisfies a type where all of them are optional. Write { colour: 'red' } where { color?: string } was expected and the fresh-literal rule catches the British spelling. Route the same object through a variable and it is a perfectly valid value of a type whose every member is optional, so nothing is reported and the option is silently ignored at runtime.
The lesson is not to avoid optional properties, it is that a type made entirely of optional properties has almost no assertive power, and options objects should be validated rather than merely annotated. Where the distinction between absent and explicitly undefined matters, exactOptionalPropertyTypes makes the compiler stop conflating them.
Extra properties are legal because assignability is structural, and the fresh-literal check is a one-time courtesy at the assignment site — so narrowing what a client receives requires constructing a new object, never a return-type annotation on the old one.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).