🔒 fix!: refuse names that no longer reach their property

A rename did not actually take effect. @JsonProperty stopped the old name
from being *mapped*, but not from being *accepted*: unlike @JsonReadOnly,
whose JSON name goes into the blocked set, the old key fell through to the
unknown-key policy, and the default `allow` copied it onto the instance
untouched.

The value therefore landed on a declared property having skipped everything
declared for it:

  @JsonProperty('home_address') @JsonType(() => Addr) @ValidateNested()
  address!: Addr;

  toInstanceSync(Order, { address: { city: 'Paris' } })
    -> address is a plain object, instanceof Addr === false
    -> validateSync() returns []          <- nothing complains

A payload aimed at the previous version of a class was accepted in part, in
silence. Three routes led to the same hole, and all three are now closed:

- the property key of a field renamed with @JsonProperty
- the raw key of a field a naming strategy renders differently
  (`firstName` under snake_case)
- the property key of a field that is both renamed and @JsonReadOnly, which
  was still settable under its own key

Refused, not swallowed. A stale name is a mismatch with whatever produced
the payload, not a deliberate refusal like @JsonReadOnly, so it is kept in
its own map rather than lumped into `blocked`: under unknownKeys: 'error'
it is still reported, and the report now names the property it was reaching
for and what that property is called now.

  "ref" is not a JSON name for Order: property "ref" is mapped to
  "order_ref". Send that name, or add @JsonAlias("ref") to keep
  accepting this one.

@JsonAlias still keeps an old name working, and a key that some *other*
property legitimately answers to is still mapped to that property — both
asserted. The resolution happens once when the name map is built, which is
memoized per class and naming strategy, so deserialization is unchanged at
~45µs for 50 nested orders.

The behaviour this replaces was pinned by tests two commits ago, pending
this decision; those tests now assert the fix, and six more cover the
naming-strategy, read-only, alias and key-collision cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 14:42:44 +00:00
parent 057f008ac0
commit 478f852b42
7 changed files with 186 additions and 76 deletions
+9 -6
View File
@@ -507,12 +507,15 @@ it is one `Symbol.toStringTag` read per object.
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
the instance you get back from `toInstance`, not the raw payload.
- **Renaming is not backwards-compatible by itself.** Once a property carries
`@JsonProperty`, its original name no longer *maps* to it. Under the default
`unknownKeys: 'allow'` it is not rejected either: it is copied onto the instance raw,
skipping any `@JsonType` or `@JsonDeserialize` conversion declared for that field, so the
property ends up holding a plain object that `@ValidateNested` then finds nothing wrong
with. Add `@JsonAlias` to keep older clients working, or set `unknownKeys` to `strip` or
`error`.
`@JsonProperty`, its original name no longer reaches it — and is refused rather than copied
onto the instance behind the rename's back. Add `@JsonAlias` to keep older clients working.
Under `unknownKeys: 'error'` the stale name is reported along with what the property is
called now:
```
JsonMappingError: "ref" is not a JSON name for Order: property "ref" is mapped to
"order_ref". Send that name, or add @JsonAlias("ref") to keep accepting this one.
```
## Contributing