Description
validate / validateSync scans every registered validation class on each call to resolve inherited metadata, with no caching. Cost is O(N) in the total number of decorated classes in the app.
The scan is in MetadataStorage.getTargetValidationMetadatas:
// src/metadata/MetadataStorage.ts
for (const [key, value] of this.validationMetadatas.entries()) {
if (targetConstructor.prototype instanceof key) {
filteredForInheritedMetadatasSearch.push(...value);
}
}
Metadata is stored as Map<declaredClass, rules[]>, so there's no reverse index to a class's ancestors — the only way to find them is to test every registered class, on every call, even though resolved metadata is immutable after registration. A 2-property value object pays the same scan as the largest DTO, and a class extending a base with zero rules still scans everything for nothing.
Minimal code-snippet showcasing the problem
import 'reflect-metadata';
import { IsString, IsNotEmpty, validateSync } from 'class-validator';
abstract class Base {}
class SmallVO extends Base {
@IsString()
@IsNotEmpty()
a!: string;
@IsString()
@IsNotEmpty()
b!: string;
constructor(a: string, b: string) {
super();
this.a = a;
this.b = b;
}
}
// register N unrelated decorated classes to simulate a large app
function register(n: number): void {
for (let i = 0; i < n; i++) {
class Dummy {
@IsString()
x!: string;
}
void Dummy;
}
}
function perCall(iter = 200_000): number {
const obj = new SmallVO('x', 'y');
const t = process.hrtime.bigint();
for (let i = 0; i < iter; i++) validateSync(obj);
return Number(process.hrtime.bigint() - t) / iter / 1000; // µs
}
let done = 0;
for (const n of [10, 100, 500, 1000, 2000]) {
register(n - done);
done = n;
console.log(n, perCall().toFixed(2), 'µs');
}
Same object, only N changes. Measured on Node v24.18.0 (arm64), Docker --cpus=2 --memory=1g, class-validator 0.14.4 (0.15.1 has the identical loop at MetadataStorage.ts:119-120); per-call is the average of 3 reps:
| N (registered classes) |
per-call |
| 10 |
1.02 µs |
| 100 |
2.99 µs |
| 500 |
12.13 µs |
| 1,000 |
23.88 µs |
| 2,000 |
47.40 µs |
A clean O(N): the same object costs ~46x more to validate in a large app than in a small one, purely because unrelated classes exist.
Expected behavior
Resolved metadata per (targetConstructor, options) is computed once and reused (it's immutable after registration), so per-call cost depends on the object, not on N. Memoizing keyed by (target, schema, always, strictGroups, groups) with invalidation on addValidationMetadata achieves this.
Actual behavior
The full O(N) scan re-runs on every call. Per-call cost grows linearly with the number of registered classes in the app.
I noticed the PR below already does pretty much this (metadata memoization, ~2.5–3x faster):
The thing is, it isn't tied to any issue, and it's been sitting for about 5 months now — the last thing on it was a review asking to fix merge conflicts and add a few tests, and nothing since. So I figured it's worth opening an issue to at least track the problem itself.
If it'd help, I'm happy to pick this up — either take #2665 the rest of the way (rebase, tests) or put up a fresh PR if the original author has moved on. Just wanted to ask which you'd prefer before I start on anything. Thanks for all the work on this library! 👍
Description
validate/validateSyncscans every registered validation class on each call to resolve inherited metadata, with no caching. Cost is O(N) in the total number of decorated classes in the app.The scan is in
MetadataStorage.getTargetValidationMetadatas:Metadata is stored as
Map<declaredClass, rules[]>, so there's no reverse index to a class's ancestors — the only way to find them is to test every registered class, on every call, even though resolved metadata is immutable after registration. A 2-property value object pays the same scan as the largest DTO, and a class extending a base with zero rules still scans everything for nothing.Minimal code-snippet showcasing the problem
Same object, only N changes. Measured on Node v24.18.0 (arm64), Docker
--cpus=2 --memory=1g, class-validator 0.14.4 (0.15.1 has the identical loop atMetadataStorage.ts:119-120); per-call is the average of 3 reps:A clean O(N): the same object costs ~46x more to validate in a large app than in a small one, purely because unrelated classes exist.
Expected behavior
Resolved metadata per
(targetConstructor, options)is computed once and reused (it's immutable after registration), so per-call cost depends on the object, not on N. Memoizing keyed by(target, schema, always, strictGroups, groups)with invalidation onaddValidationMetadataachieves this.Actual behavior
The full O(N) scan re-runs on every call. Per-call cost grows linearly with the number of registered classes in the app.
I noticed the PR below already does pretty much this (metadata memoization, ~2.5–3x faster):
The thing is, it isn't tied to any issue, and it's been sitting for about 5 months now — the last thing on it was a review asking to fix merge conflicts and add a few tests, and nothing since. So I figured it's worth opening an issue to at least track the problem itself.
If it'd help, I'm happy to pick this up — either take #2665 the rest of the way (rebase, tests) or put up a fresh PR if the original author has moved on. Just wanted to ask which you'd prefer before I start on anything. Thanks for all the work on this library! 👍