skip to content

A TypeScript service has `function emit(e: { id: string }) { queue.push(JSON.stringify(e)) }`. In production the queued payloads contain fields nobody declared, including sensitive ones. Explain why the type system permitted this and how you would prevent it.

level: seniorimportance: should knowfreq 36%

answer

  1. types state a minimum, not a maximum
  2. the annotation never reaches runtime
  3. no exact object types in the language
  4. construct the payload, don't forward it

basics

~20 s

An object type is a lower bound: it requires at least the declared members and never forbids others. Callers legitimately pass wider objects, and since types are erased, the extra fields are still there when JSON.stringify walks the value at runtime.

solid answer

~50 s

Two rules combine. First, assignability only checks that the target's members are present and compatible, so a caller may pass any wider object — that is width subtyping, and it is by design. Second, the annotation is erased at compile time; nothing at runtime trims the object to the declared shape. `JSON.stringify` therefore serialises whatever the value actually carries. TypeScript has no *exact* object type that would mean "these members and no others", so no annotation can close this. The fix is to stop forwarding the caller's object and construct the payload explicitly — `JSON.stringify({ id: e.id })` — or run it through a schema validator that outputs a fresh object at the boundary. Note that the excess-property check on inline literals does not help here: it only fires on fresh literals written at the call site, and real callers pass variables.

code

typescript · 16 lines
typescript
interface AuditEvent { id: string }

const queue: string[] = [];

function emitLeaky(e: AuditEvent) {
  queue.push(JSON.stringify(e));
}

function emitSafe(e: AuditEvent) {
  queue.push(JSON.stringify({ id: e.id }));
}

const record = { id: "e1", ssn: "123-45-6789" };
emitLeaky(record);
emitSafe(record);
console.log(queue);

go deeper

for a junior

Understand that the annotation on a parameter describes what the function may safely read, and that the object handed in can carry more than that — the type never trims the value.

for a middle

Explain both mechanisms together: width subtyping makes the wider argument legal, and erasure means nothing narrows the object before JSON.stringify walks it. Say that no annotation can close the gap.

for a senior

Show the production reflex — construct the outbound shape explicitly or rebuild it through a validator at the boundary — and generalise to every operation that enumerates a value: serialisation, spreads, key iteration, ORM writes.

for a principal

Own the policy: because the language has no exact object types, "no unexpected fields on the wire" is an architectural invariant enforced by one mapping layer and a review rule, not something a stricter compiler setting will ever give you.

## The two facts that produce the leak **1. An object type states a minimum, not a maximum.** Assignability walks the members the *target* declares and asks whether the source supplies each one compatibly. It never enumerates the source looking for extras. So `{ id: string }` reads as "at least a string `id`" and every wider object satisfies it: ```ts interface AuditEvent { id: string } function emit(e: AuditEvent) { queue.push(JSON.stringify(e)); } const record = { id: "e1", ssn: "123-45-6789", internalNotes: "..." }; emit(record); // compiles — and the ssn is now in the queue ``` This is not a hole in the checker; it is width subtyping, and it is what lets a function that needs one field accept a rich domain object without adapters everywhere. **2. The annotation does not survive to runtime.** Types are erased. The emitted JavaScript has no notion of `AuditEvent`, no filter, no projection. `e` is the very object the caller passed, and `JSON.stringify` walks its own enumerable properties — all of them. Nothing narrowed the object; only the *view* the compiler gave the function body was narrow. Candidates who understand only one of these facts give half an answer. Width subtyping explains why the call compiled; erasure explains why the extra data is still present when the payload is built. ## Why no annotation can fix it The instinct is to look for a stricter type. There isn't one. TypeScript has no **exact object type** meaning "these members and no others", so you cannot declare a parameter that rejects wider arguments in general. The related mechanisms all miss: - **Excess property checks** fire only on *fresh object literals* written directly at the call site. A caller passing a variable — the realistic case — is unaffected. - **`Omit<T, "ssn">`** and mapped types transform types, not values. `Omit` deletes nothing at runtime; it changes what the body may read, and the object still carries the member. - **`readonly`, `satisfies`, assertions** — all compile-time only, none of them touch the value. So the defence has to live in code that runs. ## The fixes, in order of preference **Construct the payload explicitly.** The narrow shape becomes a value, not just a type: ```ts function emit(e: AuditEvent) { queue.push(JSON.stringify({ id: e.id })); } ``` This is the one-line fix and it is exact by construction. It scales badly only when the payload is large, and then a projection helper serves the same purpose. **Validate and re-emit at the boundary.** Where the data crosses a trust or process boundary, run it through a schema validator whose output is a *new* object built from the recognised members. That gives you the same guarantee plus rejection of malformed input, and it is the right shape of defence for anything arriving from the network. **Serialise deliberately.** Give the payload type a `toJSON`, or map the domain object to a DTO in one designated place. What you are buying in every variant is the same thing: the serialised object is one you built, not one you were handed. **Do not** reach for a runtime `delete` of known-bad keys. That is a denylist, and a denylist over an open shape fails the moment someone adds a field upstream. ## The judgment an interviewer is listening for The strong answer names both mechanisms, says plainly that no type annotation can close the gap because exact object types do not exist, and then treats it as a boundary-design problem rather than a typing problem. The generalisation is worth stating: **anything that enumerates a value — `JSON.stringify`, `Object.keys`, object spread into a request body, an ORM insert, a log line — sees the real object, not the declared type.** Wherever one of those meets data that arrived from outside the function, the shape must be constructed, not assumed. A useful closing observation: this is one of the places where TypeScript's usefulness stops at the compile boundary by design. The type layer buys you a description of what your code may *rely on*; it never buys you a guarantee about what is *present*. Treating those as the same thing is the actual defect that produced the incident.

  • Would typing the parameter as `Omit<FullRecord, "ssn">` have prevented the leak?
    No. `Omit` produces a type, and types are erased. The body would no longer be allowed to read `ssn`, but the object passed in still carries it, and `JSON.stringify` still serialises it. Utility types change what code may rely on, never what a value contains at runtime.
  • Why doesn't the excess-property check catch this at the call site?
    Because it applies only to fresh object literals written directly at the argument position. A caller holding the object in a variable — which is what real code does — is assigned by the ordinary width-subtyping rule, where extra members are legal. The check is a typo-catcher for literals, not a shape enforcer.
  • Where in a system would you place the projection so this class of bug cannot recur?
    At the boundary where the payload is created — one designated mapping from domain object to DTO, ideally the only place that constructs the wire shape. Combining it with a schema that both validates and rebuilds the object gives you an allowlist by construction, and a review rule that no domain object is ever serialised directly.

saying these in an interview costs you the question

  • Says the type annotation guarantees only those fields exist at runtime
  • Proposes deleting known sensitive keys before serialising
  • Thinks Omit or Pick removes properties from the value
  • Blames the caller for passing a wider object rather than the design
  • Suggests an `as` assertion narrows the object at runtime

context