You pass an object with extra properties to something typed to accept fewer. When does TypeScript complain, and when does it not?
Assignability in TypeScript is structural, so extra properties are fine; the only exception is excess property checking on a fresh object literal. Route the same object through a variable and the extra properties pass silently, which is how fields you never declared reach a JSON response.
What the interviewer is scoring
- Whether the candidate gives structural assignability as the rule and the literal check as the exception, not the reverse
- Does the answer name freshness rather than describing the behaviour as TypeScript sometimes checking extra keys
- That an intermediate variable, an as assertion and a spread are each identified as things that discard the check
- Whether they connect this to serialisation emitting properties the declared type never promised
- Does the candidate reach for an explicit projection or satisfies rather than trusting a return-type annotation
Answer
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$...' };
// Rejected. Fresh literal, and passwordHash is not a known property of PublicUser.
const a: PublicUser = { id: 'u_1', email: 'a@b.com', passwordHash: '$2b$10$...' };
// Accepted. row is not fresh, and structurally it is a PublicUser.
const b: PublicUser = row; // <-- passwordHash is still on the object at runtime
res.json(b); // JSON.stringify walks the object's own properties, hash included
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.
Likely follow-ups
- What does satisfies check that a type annotation does not, and when would you choose each?
- A spread of two objects is assigned to a narrow type and no error appears. Why not?
- How does excess property checking behave when the target is a union of object types?
- Where would you put a test that fails when a newly added database column starts appearing in an API response?
Related questions
- How do you make the compiler fail when somebody adds a new variant to a union you switch on?mediumAlso on typescript and type-safety5 min
- How do you use TypeScript to make a component's props hard to misuse?mediumAlso on typescript4 min
- Your endpoint takes a typed request body and it still crashed on a missing field. How is that possible when it compiled?mediumAlso on typescript4 min
- When would you reach for an abstract base class rather than a Protocol, and what does isinstance check in each case?hardAlso on structural-typing5 min
- Serialise a binary tree to a string and reconstruct exactly the same tree from it. What has to be in the string?mediumAlso on serialisation4 min
- How do you use TypeScript's type system to make invalid states unrepresentable in a service?mediumAlso on type-safety4 min
- Where does TypeScript stop protecting you, and what do you do at those points?hardAlso on typescript4 min
- A finished unit fails test, is stripped, gets a replacement component and passes on the second attempt. What has to happen to its genealogy record?hardAlso on serialisation7 min