A TypeScript update endpoint types its request body as `Partial<User>` and applies it with `return { ...user, ...patch }`. Which update bugs does that typing still permit?
answer
- the type is looser than the endpoint
- every key is patchable, including id
- absent and explicitly undefined differ
- the spread copies a present undefined key
- result is typed User and is not one
basics
~20 sPartial<User> permits every field to be patched, including identity fields that must never change; it accepts an empty object; and it accepts an explicit undefined for any property, which the spread then copies over a real value, leaving a User whose required field is undefined while the type still claims it is present.
solid answer
~40 s`Partial<User>` says "any subset of User's fields", which is looser than any real update endpoint. Three things get through. First, every field is patchable — including `id` — because the type has no notion of which fields the endpoint accepts. Second, `{}` typechecks, so the type cannot demand a non-empty patch. Third, and the one that actually bites: under default settings an optional property accepts an explicit `undefined`, so `{ name: undefined }` is a valid `Partial<User>`, and merging it overwrites a real name with `undefined` while the result is still typed `User` with `name: string`. `exactOptionalPropertyTypes` closes that third hole at the type level. The real fix is to hand-write a patch type naming the fields the endpoint accepts, and to validate the body at the boundary rather than asserting its shape.
code
typescript · 14 linestype User = { id: number; name: string; email: string };
function applyPatch(user: User, patch: Partial<User>): User {
return { ...user, ...patch };
}
const stored: User = { id: 1, name: "ada", email: "[email protected]" };
// both of these typecheck under default settings
applyPatch(stored, { id: 999 });
const wiped = applyPatch(stored, { name: undefined });
// wiped is typed User, so wiped.name is string — at run time it is undefined
console.log(wiped.name);go deeper
Know that Partial<T> makes every property optional, so an empty object and an object containing any subset of fields both satisfy it — the type demands nothing.
Explain that an optional property accepts an explicit undefined by default, and that spreading an object which carries that key overwrites a real value, so the merged result contradicts its own declared type.
Diagnose all of it in one pass — over-wide key set, empty patch, undefined clobbering, no way to express clearing — and propose the fix: a hand-written patch type, exactOptionalPropertyTypes, and validation at the boundary.
Own the boundary policy: whether request contracts may be derived from storage types at all, where unvalidated data is allowed to acquire a type, and how that is enforced consistently across services rather than per handler.
## Why this shape looks right and is not `Partial<T>` is the reflex answer for a patch payload, and it is not wrong so much as *underspecified*. The type it produces is `{ [P in keyof User]?: User[P] }` — literally "zero or more of these fields, each with its declared type". An update endpoint almost never means exactly that. ## Hole one: every field is patchable `keyof User` includes `id`, `createdAt`, `role`, and anything else the caller has no business changing. `Partial<User>` accepts `{ id: 999 }` with no complaint. If the merge is a blind spread, the record's identity changes and the type system was fine with it the whole way. The fix is not a runtime check bolted on afterwards; it is naming a smaller type. Write the patch type explicitly — `type UserPatch = { name?: string; email?: string }` — so the compiler rejects an unknown or forbidden field at the call site through the excess property check. ## Hole two: the empty patch `{}` is a valid value of `Partial<User>` for any `User`. The type cannot express "at least one field". A handler that assumes something was sent will happily process a no-op update, and any "nothing changed" logic has to be written by hand and cannot be enforced by this type. ## Hole three: the explicit undefined This is the one worth being able to explain cold. Under default compiler settings, an optional property `name?: string` accepts the value `undefined` as well as being absent. So: ```typescript type User = { id: number; name: string; email: string }; function applyPatch(user: User, patch: Partial<User>): User { return { ...user, ...patch }; } applyPatch(stored, { name: undefined }); // typechecks ``` The spread copies the key `name` — it is present on the patch object, and its value happens to be `undefined` — so the merged result has `name: undefined`. The declared return type is `User`, in which `name` is `string`, and the compiler agrees: the spread of `User` and `Partial<User>` produces a type assignable to `User`, because for each property it takes the union of the two operands' types, and the optional side contributes nothing that would break it. You now hold a value the type system swears is a `User` with a `string` name, and at run time that name is `undefined`. Everything downstream — a `name.trim()`, a template string, a database write — inherits the lie. Two levers close this: - **`exactOptionalPropertyTypes`** makes an optional property mean "absent or of the declared type", so an explicitly written `undefined` is rejected unless the declaration says `| undefined`. That kills the bad call site at compile time. - **Strip undefined values before merging.** Even with the flag on, a payload parsed from JSON has never been near the checker, so the merge itself should skip keys whose value is `undefined` rather than trusting the type. ## Hole four: absence and clearing are the same shape A patch language usually needs three states per field: leave it alone, set it to a value, clear it. `Partial<T>` gives you two, and it maps "absent" and "undefined" onto the same practical meaning. If a field is nullable and clearing it is meaningful, the type has to say so — `email?: string | null` — and the merge has to distinguish `null` (clear) from absent (leave). No generic transform can invent that distinction for you. ## The framing error underneath all four The deepest issue is that `Partial<User>` is a *derived* type: it says the patch shape is whatever the storage shape happens to be, minus required-ness. Request bodies and stored records are different contracts that drift apart — a stored record grows an internal field, and the endpoint silently starts accepting it. A hand-written patch type is a few more lines and states the API's actual contract, which is what you want a reviewer to read. And the type is an assumption, not a check. A body arriving over the network is `unknown` in truth; whatever annotation the handler carries came from an assertion somewhere upstream. Validate at the boundary, then let the checked, narrower type flow inward. ## What a strong answer sounds like Name the explicit-undefined hole first, because it is the one that produces a value that contradicts its own type; then the over-wide key set; then the fact that the type is derived from storage rather than describing the API. Offering `exactOptionalPropertyTypes` shows you know the compiler lever, and offering boundary validation shows you know the lever is not enough on its own.
- How would you type the patch so the identity fields cannot be touched?Write the patch type by hand and list only the mutable fields — `type UserPatch = { name?: string; email?: string }`. That states the API contract instead of deriving it from the storage shape, and the excess property check rejects an object literal carrying `id`. Deriving it from `User` couples the endpoint to every future field added to the record.
- Does enabling `exactOptionalPropertyTypes` make the merge safe?It makes the *call site* safe: an object literal with `{ name: undefined }` no longer satisfies `Partial<User>` unless the declaration explicitly allows undefined. It does nothing for a body that was parsed from JSON and asserted into that type without ever being checked. You still want the merge to skip keys whose value is undefined, and validation at the boundary.
- How would you support clearing a nullable field in the same patch type?Model the three states explicitly. Declare the field as `email?: string | null`, where absent means leave it alone and `null` means clear it, and write the merge so it distinguishes the two rather than spreading blindly. `Partial<T>` alone cannot express that distinction, because it only ever adds optionality to whatever the field already was.
saying these in an interview costs you the question
- Assumes an optional property rejects an explicitly written undefined
- Thinks the spread skips keys whose value is undefined
- Trusts that a request body matches its annotation without validation
- Says Partial<User> prevents the client from sending id
- Believes a declared return type of User makes the returned value valid