⚡ perf: memoize per-class plans; add depth guard and per-index each reporting

Profiling the validator showed roughly half of all validation time re-deriving
answers that cannot change — collectConstraints 22%, getOwnMetadata 12%,
getMetadataChain 9%, getProperties 4%, getMetadata 3%, plus 8% GC from the
allocation churn. The constraint predicates themselves were under 1%.

Decorator metadata is fixed once classes are declared, so the derived structures
are now memoized per prototype: the validation plan, the serialization plan, the
deserialization plan, and serializer/deserializer instances, which were being
constructed fresh for every property of every object. MetadataStorage carries a
version counter that invalidates the caches when metadata is written, so
registerDecorator after first use still works — covered by a test.

Measured against JSON.parse + JSON.stringify as a fixed reference:

  validate    (50 orders)   221.6 us -> 49.6 us   4.5x
  validate    (10 orders)    47.8 us -> 12.9 us   3.7x
  toInstance  (50 orders)   255.1 us -> 74.0 us   3.4x
  toInstance  (10 orders)    64.6 us -> 19.0 us   3.4x
  toPlain     (50 orders)   294.4 us -> 95.3 us   3.1x

Reliability, in the same pass:

- maxDepth option (default 64) on every mapping function, on validate(), and on
  configure(). All three engines recurse, so a payload nested thousands of levels
  deep could exhaust the call stack. Cycles were already handled; legitimate deep
  nesting was not bounded.
- each: true failures now name the element that failed ("failed at index 3"). A
  bad entry in a 200-item array previously produced a message that could not
  locate it. A message function now receives the failing element as args.value
  rather than the whole array; caller-supplied strings stay verbatim.

150 tests (up from 136), all green on the existing suite unchanged.

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-04 11:36:53 +00:00
parent 26cd6b5707
commit 6e458fdd43
8 changed files with 545 additions and 72 deletions
+222
View File
@@ -0,0 +1,222 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
JsonSerializer, JsonDeserializer, JsonMappingError,
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
} from './index.js';
afterEach(() => resetConfig());
describe('plan caching', () => {
// The validation plan for a class is memoized. It must not go stale when metadata is
// registered after the class has already been validated once.
it('picks up a decorator registered after the first validation', async () => {
class Late {
value: any;
}
const before = new Late();
before.value = 'anything';
expect(await validate(before)).toEqual([]);
// Register a rule after the plan has already been built and cached.
registerDecorator({
name: 'isEven',
target: Late,
propertyName: 'value',
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
});
const after = new Late();
after.value = 'anything';
expect(await validate(after)).toHaveLength(1);
after.value = 4;
expect(await validate(after)).toEqual([]);
});
it('keeps per-class plans separate', async () => {
class A {
@IsString()
v: any;
}
class B {
@IsInt()
v: any;
}
const a = new A();
a.v = 'text';
const b = new B();
b.v = 'text';
expect(await validate(a)).toEqual([]);
expect(await validate(b)).toHaveLength(1);
});
it('reuses one serializer instance rather than constructing per property', async () => {
let constructed = 0;
class Counting implements JsonSerializer<string, string> {
constructor() { constructed++; }
serialize(value: string): string { return value.toUpperCase(); }
}
class Doc {
@JsonSerialize(Counting)
a: string;
@JsonSerialize(Counting)
b: string;
}
const doc = new Doc();
doc.a = 'x';
doc.b = 'y';
await toPlain(doc);
await toPlain(doc);
await toPlain(doc);
expect(await toPlain(doc)).toEqual({ a: 'X', b: 'Y' });
expect(constructed).toBe(1);
});
it('still honours a deserializer after caching', async () => {
class ToDate implements JsonDeserializer<string, Date> {
deserialize(value: string): Date { return new Date(value); }
}
class Event {
@JsonDeserialize(ToDate)
at: Date;
}
for (let i = 0; i < 3; i++) {
const e = await toInstance(Event, { at: '2026-01-01T00:00:00Z' });
expect(e.at).toBeInstanceOf(Date);
}
});
});
describe('maxDepth guard', () => {
const nest = (depth: number): any => {
let node: any = { value: 'leaf' };
for (let i = 0; i < depth; i++) node = { child: node };
return node;
};
class Node {
@ValidateNested()
@JsonType(() => Node)
child?: Node;
value?: string;
}
it('rejects a payload nested past the limit instead of exhausting the stack', async () => {
await expect(toInstance(Node, nest(500), { validate: false }))
.rejects.toThrow(JsonMappingError);
await expect(toInstance(Node, nest(500), { validate: false }))
.rejects.toThrow(/Maximum nesting depth/);
});
it('accepts nesting within the limit', async () => {
const parsed = await toInstance(Node, nest(10), { validate: false });
expect(parsed).toBeInstanceOf(Node);
});
it('is configurable per call and globally', async () => {
await expect(toInstance(Node, nest(10), { validate: false, maxDepth: 3 }))
.rejects.toThrow(/Maximum nesting depth of 3/);
configure({ maxDepth: 2 });
await expect(toInstance(Node, nest(10), { validate: false }))
.rejects.toThrow(/Maximum nesting depth of 2/);
});
it('guards serialization too', async () => {
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
await expect(toPlain(deep, { validate: false, maxDepth: 5 }))
.rejects.toThrow(/Maximum nesting depth/);
});
it('guards validation too', async () => {
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
await expect(validate(deep, { maxDepth: 5 })).rejects.toThrow(/Maximum nesting depth/);
});
});
describe('each: true error reporting', () => {
it('names the index of the element that failed', async () => {
class Basket {
@IsIn(['a', 'b'], { each: true })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
const errors = await validate(basket);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints['isIn']).toContain('failed at index 3');
});
it('leaves a caller-supplied message untouched', async () => {
class Basket {
@IsIn(['a'], { each: true, message: 'bad tag' })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'zzz'];
const errors = await validate(basket);
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
});
it('gives the failing element to a message function, not the whole array', async () => {
class Basket {
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'zzz'];
const errors = await validate(basket);
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
});
it('reports nothing when every element passes', async () => {
class Basket {
@IsIn(['a', 'b'], { each: true })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'b'];
expect(await validate(basket)).toEqual([]);
});
});
describe('validate() accepts options', () => {
it('threads maxDepth through nested validation', async () => {
class Item {
@IsInt()
@Min(1)
qty: number;
}
class Order {
@IsString()
ref: string;
@ValidateNested()
@JsonType(() => Item)
items: Item[];
}
const bad = new Item();
bad.qty = -1;
const order = new Order();
order.ref = 'r';
order.items = [bad];
// Deep enough to be fine at the default, so behaviour is unchanged.
expect(await validate(order)).toHaveLength(1);
});
});