🔍 fix: correct the last three findings from the page review

Of 26 findings raised across five auditors, 23 were refuted on a second
pass. These three survived.

**A renamed property's old key is still writable.** The README, the landing
page and two doc comments all said that once a property carries
@JsonProperty its original name "is no longer accepted on input". It is no
longer *mapped* — but it is not rejected either. Unlike @JsonReadOnly,
whose JSON name goes into the blocked set, the old key falls through to the
unknown-key policy, and the default `allow` copies it onto the instance
untouched. Reproduced against dist:

  @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
    -> round-trips out as home_address    <- silently accepted

Blocking the old key would fix it, but would also swallow the
`unknownKeys: 'error'` report a strict caller gets today, which is arguably
the more useful signal. That is a judgement call the library has not made,
so this commit states the behaviour accurately everywhere it was stated
wrongly and pins it with five tests covering the default, `strip`, `error`
and the @JsonAlias fix — so it cannot drift either way while the question
is open.

**@IsNotEmpty and @IsEmpty are not complements.** `[]` and `{}` pass BOTH:
isNotEmpty checks only null/undefined/'' while isEmpty also treats empty
arrays and objects as empty. Listed one line apart as "must not be empty" /
"must be empty", they invited exactly the wrong inference.

**unknownKeys is deserialization-only**, in a group whose blurb says these
apply per call or via configure().

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 10:33:45 +00:00
parent 8c9aea3062
commit 057f008ac0
7 changed files with 136 additions and 12 deletions
+3 -1
View File
@@ -68,7 +68,9 @@ function pattern(name: string, regex: RegExp, message: (property: string) => str
* ```
*
* An explicit name always wins over the active naming strategy. Note that renaming stops the
* original name from being accepted on input — add `@JsonAlias` to keep older clients working.
* original name from *mapping* to it. Under the default `unknownKeys: 'allow'` that name is
* still copied onto the instance raw, bypassing any `@JsonType` or `@JsonDeserialize` declared
* for the field — add `@JsonAlias` to keep older clients working, or set `unknownKeys`.
*/
export function JsonProperty(name: string): FieldDecorator<unknown> {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
+89
View File
@@ -447,3 +447,92 @@ describe('a realistic API payload', () => {
expect(response).not.toContain('hunter2');
});
});
describe('renaming and the unknown-key policy', () => {
class Address {
@IsString() city!: string;
}
class Order {
@JsonProperty('order_ref')
@IsString()
ref!: string;
@JsonProperty('home_address')
@JsonType(() => Address)
@ValidateNested()
address!: Address;
}
/**
* A sharp edge, pinned here rather than fixed, because the fix is a judgement call the
* library has not made yet.
*
* `@JsonReadOnly` puts its JSON name in the blocked set, so a client cannot set it.
* `@JsonProperty` does not do the same for the property's *old* name: that name simply
* stops mapping, falls through to the unknown-key policy, and the default `allow` copies
* it onto the instance untouched. The value therefore lands on a declared property having
* skipped everything declared for it — no `@JsonType` conversion, and `@ValidateNested`
* then finds a plain object with no model and reports nothing.
*
* Blocking the old key would fix that, but it would also swallow the `unknownKeys: 'error'`
* report that a strict caller gets today, which is arguably the more useful signal. Until
* that is decided, the behaviour is documented in the README and on the landing page, and
* asserted here so it cannot change by accident.
*/
it('leaves the old key writable after a rename, under the default policy', async () => {
const order = await toInstance(
Order,
{ ref: 'A-1', address: { city: 'Paris' } },
{ validate: false }
);
expect(order.ref).toBe('A-1');
// The conversion declared for the field did not run.
expect(order.address).toEqual({ city: 'Paris' });
expect(order.address instanceof Address).toBe(false);
// And nothing complains about it.
expect(await validate(order)).toEqual([]);
});
it('maps the declared names properly, producing real instances', async () => {
const order = await toInstance(
Order,
{ order_ref: 'A-1', home_address: { city: 'Paris' } },
{ validate: false }
);
expect(order.ref).toBe('A-1');
expect(order.address instanceof Address).toBe(true);
});
it('drops the old key under unknownKeys: strip', async () => {
const order = await toInstance(
Order,
{ ref: 'A-1', address: { city: 'Paris' } },
{ validate: false, unknownKeys: 'strip' }
);
expect(order.ref).toBeUndefined();
expect(order.address).toBeUndefined();
});
it('reports the old key under unknownKeys: error', async () => {
await expect(
toInstance(Order, { ref: 'A-1' }, { validate: false, unknownKeys: 'error' })
).rejects.toThrow(/Unknown property "ref"/);
});
it('keeps the old name working properly when @JsonAlias declares it', async () => {
class Kept {
@JsonProperty('order_ref')
@JsonAlias('ref')
@IsString()
ref!: string;
}
const kept = await toInstance(Kept, { ref: 'A-1' }, { validate: false });
expect(kept.ref).toBe('A-1');
expect(await validate(kept)).toEqual([]);
});
});
+9 -4
View File
@@ -160,10 +160,15 @@ const inboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<
/**
* Builds the JSON-name -> property-key lookup used when reading a payload.
*
* Only names the class actually declares are accepted: the `@JsonProperty` name (or the
* naming strategy's rendering of the property name) plus any `@JsonAlias`. Renaming a
* property therefore stops the old name from being silently accepted — add `@JsonAlias` to
* keep it working for older clients.
* Only names the class actually declares are mapped: the `@JsonProperty` name (or the naming
* strategy's rendering of the property name) plus any `@JsonAlias`.
*
* Note what this does *not* do. A renamed property's old name stops mapping to it, but it is
* not blocked — unlike `@JsonReadOnly`, which is. It falls through to the unknown-key policy,
* and the default `allow` copies it onto the instance raw, bypassing the `@JsonType` or
* `@JsonDeserialize` declared for that field. `renames leave the old key writable` in
* mapping.test.ts pins that behaviour; see the note there for why it has not simply been
* changed.
*/
function inboundNameMap(model: ClassModel, ctx: DeserializeContext): InboundNames {
let entry = inboundCache.get(model);