⚡ 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:
+222
-66
@@ -45,12 +45,15 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
interface SerializeContext {
|
||||
naming: NamingStrategyFn;
|
||||
namingKey: unknown;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
interface DeserializeContext {
|
||||
naming: NamingStrategyFn;
|
||||
namingKey: unknown;
|
||||
unknownKeys: UnknownKeyPolicy;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +77,69 @@ function outboundName(target: any, key: string, naming: NamingStrategyFn): strin
|
||||
return explicit ?? naming(key);
|
||||
}
|
||||
|
||||
/** Per-property serialization facts, resolved once instead of per call. */
|
||||
interface OutboundProperty {
|
||||
/** The name to write in the output. */
|
||||
name: string;
|
||||
/** True for @JsonIgnore / @JsonWriteOnly — omitted from output. */
|
||||
skip: boolean;
|
||||
/** The @JsonSerialize class, if any. */
|
||||
serializer?: any;
|
||||
}
|
||||
|
||||
const outboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
||||
|
||||
/**
|
||||
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
||||
*
|
||||
* Serialization walks the runtime keys of each object, so undeclared properties turn up here
|
||||
* too; they memoize just as well, since the naming strategy is deterministic.
|
||||
*/
|
||||
function outboundFor(target: any, key: string, ctx: SerializeContext): OutboundProperty {
|
||||
if (!target) {
|
||||
// Null-prototype object: nothing is declared, so there is nothing to cache against.
|
||||
return { name: ctx.naming(key), skip: false };
|
||||
}
|
||||
|
||||
let entry = outboundCache.get(target);
|
||||
if (!entry || entry.version !== metadataStorage.version) {
|
||||
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
||||
outboundCache.set(target, entry);
|
||||
}
|
||||
|
||||
let byKey = entry.byStrategy.get(ctx.namingKey);
|
||||
if (!byKey) {
|
||||
byKey = new Map();
|
||||
entry.byStrategy.set(ctx.namingKey, byKey);
|
||||
}
|
||||
|
||||
let resolved = byKey.get(key);
|
||||
if (!resolved) {
|
||||
const access = accessOf(target, key);
|
||||
const serializer = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
|
||||
resolved = {
|
||||
name: outboundName(target, key, ctx.naming),
|
||||
// `writeonly` is accepted on input but must never be echoed back out.
|
||||
skip: access === 'none' || access === 'writeonly',
|
||||
...(serializer ? { serializer } : {}),
|
||||
};
|
||||
byKey.set(key, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Per-property deserialization facts, resolved once instead of per call. */
|
||||
interface InboundProperty {
|
||||
deserializer?: any;
|
||||
polymorphic?: any;
|
||||
typeFn?: () => ClassConstructor<any>;
|
||||
}
|
||||
|
||||
interface InboundNames {
|
||||
/** JSON name -> property key, for properties this payload is allowed to set. */
|
||||
accept: Map<string, string>;
|
||||
/** property key -> the conversion metadata that applies to it. */
|
||||
props: Map<string, InboundProperty>;
|
||||
/**
|
||||
* JSON names that belong to a declared property the payload may NOT set
|
||||
* (`@JsonIgnore` / `@JsonReadOnly`). They are dropped rather than treated as unknown
|
||||
@@ -88,7 +151,7 @@ interface InboundNames {
|
||||
|
||||
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
||||
// declared, so they are cached per (prototype, naming strategy).
|
||||
const inboundCache = new WeakMap<object, Map<unknown, InboundNames>>();
|
||||
const inboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
||||
|
||||
/**
|
||||
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
||||
@@ -99,16 +162,17 @@ const inboundCache = new WeakMap<object, Map<unknown, InboundNames>>();
|
||||
* keep it working for older clients.
|
||||
*/
|
||||
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
||||
let byStrategy = inboundCache.get(target);
|
||||
if (!byStrategy) {
|
||||
byStrategy = new Map();
|
||||
inboundCache.set(target, byStrategy);
|
||||
let entry = inboundCache.get(target);
|
||||
if (!entry || entry.version !== metadataStorage.version) {
|
||||
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
||||
inboundCache.set(target, entry);
|
||||
}
|
||||
const cached = byStrategy.get(ctx.namingKey);
|
||||
const cached = entry.byStrategy.get(ctx.namingKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const accept = new Map<string, string>();
|
||||
const blocked = new Set<string>();
|
||||
const props = new Map<string, InboundProperty>();
|
||||
|
||||
const claim = (external: string, key: string) => {
|
||||
const owner = accept.get(external);
|
||||
@@ -134,18 +198,36 @@ function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
||||
}
|
||||
|
||||
for (const name of names) claim(name, key);
|
||||
|
||||
const deserializer = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
||||
const polymorphic = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
|
||||
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
||||
if (deserializer || polymorphic || typeFn) {
|
||||
props.set(key, {
|
||||
...(deserializer ? { deserializer } : {}),
|
||||
...(polymorphic ? { polymorphic } : {}),
|
||||
...(typeFn ? { typeFn } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = { accept, blocked };
|
||||
byStrategy.set(ctx.namingKey, result);
|
||||
const result = { accept, blocked, props };
|
||||
entry.byStrategy.set(ctx.namingKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext): Promise<any> {
|
||||
async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number): Promise<any> {
|
||||
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (depth > ctx.maxDepth) {
|
||||
throw new JsonMappingError(
|
||||
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing. ` +
|
||||
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||
);
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return obj.toISOString();
|
||||
}
|
||||
@@ -162,7 +244,7 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
||||
if (Array.isArray(obj)) {
|
||||
const out: any[] = [];
|
||||
for (const item of obj) {
|
||||
out.push(await serialize(item, ancestors, ctx));
|
||||
out.push(await serialize(item, ancestors, ctx, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -171,21 +253,17 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
||||
|
||||
const result: any = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const access = accessOf(target, key);
|
||||
// `writeonly` is accepted on input but must never be echoed back out.
|
||||
if (access === 'none' || access === 'writeonly') continue;
|
||||
const property = outboundFor(target, key, ctx);
|
||||
if (property.skip) continue;
|
||||
|
||||
const value = obj[key];
|
||||
const name = outboundName(target, key, ctx.naming);
|
||||
|
||||
// Custom serializers only see real values. Handing a serializer `undefined` for a
|
||||
// property that was simply never set turns an optional field into a crash.
|
||||
const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
|
||||
if (serializerCls && value !== null && value !== undefined) {
|
||||
const serializer = new serializerCls();
|
||||
result[name] = await serializer.serialize(value);
|
||||
if (property.serializer && value !== null && value !== undefined) {
|
||||
result[property.name] = await converterFor(property.serializer).serialize(value);
|
||||
} else {
|
||||
result[name] = await serialize(value, ancestors, ctx);
|
||||
result[property.name] = await serialize(value, ancestors, ctx, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,11 +275,18 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
||||
}
|
||||
}
|
||||
|
||||
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext): Promise<T> {
|
||||
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext, depth: number): Promise<T> {
|
||||
if (plain === null || plain === undefined) return plain;
|
||||
|
||||
if (depth > ctx.maxDepth) {
|
||||
throw new JsonMappingError(
|
||||
`Maximum nesting depth of ${ctx.maxDepth} exceeded while deserializing. ` +
|
||||
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(plain)) {
|
||||
const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx)));
|
||||
const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx, depth + 1)));
|
||||
return results as any;
|
||||
}
|
||||
|
||||
@@ -235,24 +320,24 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
|
||||
|
||||
const value = plain[incoming];
|
||||
|
||||
const property = inbound.props.get(key);
|
||||
|
||||
// Custom Deserializer
|
||||
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
||||
if (deserializerCls) {
|
||||
const deserializer = new deserializerCls();
|
||||
instance[key as keyof T] = await deserializer.deserialize(value);
|
||||
if (property?.deserializer) {
|
||||
instance[key as keyof T] = await converterFor(property.deserializer).deserialize(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Polymorphic
|
||||
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
|
||||
const poly = property?.polymorphic;
|
||||
if (poly && value !== null && value !== undefined) {
|
||||
const { discriminator, subTypes, onUnknown, fallback } = poly;
|
||||
|
||||
const resolve = async (item: any): Promise<any> => {
|
||||
if (item === null || item === undefined || typeof item !== 'object') return item;
|
||||
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
|
||||
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx);
|
||||
if (fallback) return deserialize(fallback, item, ctx);
|
||||
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx, depth + 1);
|
||||
if (fallback) return deserialize(fallback, item, ctx, depth + 1);
|
||||
if (onUnknown === 'error') {
|
||||
throw new JsonMappingError(
|
||||
`Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
|
||||
@@ -270,10 +355,10 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
|
||||
}
|
||||
|
||||
// Nested Type
|
||||
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
||||
const typeFn = property?.typeFn;
|
||||
if (typeFn && value !== null && value !== undefined) {
|
||||
const type = typeFn();
|
||||
instance[key as keyof T] = await deserialize(type, value, ctx);
|
||||
instance[key as keyof T] = await deserialize(type, value, ctx, depth + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -313,6 +398,63 @@ function collectConstraints(target: any, key: string): ValidationConstraint[] {
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the validator needs to know about one property, resolved once.
|
||||
*/
|
||||
interface PropertyPlan {
|
||||
key: string;
|
||||
constraints: ValidationConstraint[];
|
||||
isOptional: boolean;
|
||||
isNested: boolean;
|
||||
condition?: (object: any) => boolean;
|
||||
}
|
||||
|
||||
interface CachedPlan {
|
||||
version: number;
|
||||
plan: PropertyPlan[];
|
||||
}
|
||||
|
||||
// Resolving a class's validation rules means walking its prototype chain several times per
|
||||
// property, per call — which profiling showed to be roughly half of all validation time,
|
||||
// recomputing an answer that cannot change. The result is memoized per prototype and
|
||||
// invalidated by MetadataStorage's version counter, so metadata registered late still works.
|
||||
const planCache = new WeakMap<object, CachedPlan>();
|
||||
|
||||
function validationPlan(target: any): PropertyPlan[] {
|
||||
const cached = planCache.get(target);
|
||||
if (cached && cached.version === metadataStorage.version) {
|
||||
return cached.plan;
|
||||
}
|
||||
|
||||
const plan: PropertyPlan[] = [];
|
||||
for (const key of metadataStorage.getProperties(target)) {
|
||||
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
|
||||
plan.push({
|
||||
key,
|
||||
constraints: collectConstraints(target, key),
|
||||
isOptional: !!metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key),
|
||||
isNested: !!metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key),
|
||||
...(condition ? { condition } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
planCache.set(target, { version: metadataStorage.version, plan });
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Serializers and deserializers are stateless by contract, so one instance per class is
|
||||
// enough. Constructing a fresh one for every property of every object was pure waste.
|
||||
const converterCache = new WeakMap<object, any>();
|
||||
|
||||
function converterFor(clazz: any): any {
|
||||
let instance = converterCache.get(clazz);
|
||||
if (!instance) {
|
||||
instance = new clazz();
|
||||
converterCache.set(clazz, instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a failure without letting a later constraint overwrite an earlier one that happens
|
||||
* to share a name (two `@Min` rules, or a rule inherited and re-declared).
|
||||
@@ -327,10 +469,17 @@ function recordFailure(constraints: { [key: string]: string }, name: string, mes
|
||||
constraints[`${name}_${suffix}`] = message;
|
||||
}
|
||||
|
||||
async function validateInternal(obj: any, ancestors: Set<any>): Promise<ValidationError[]> {
|
||||
async function validateInternal(obj: any, ancestors: Set<any>, depth: number, maxDepth: number): Promise<ValidationError[]> {
|
||||
const errors: ValidationError[] = [];
|
||||
if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
|
||||
|
||||
if (depth > maxDepth) {
|
||||
throw new JsonMappingError(
|
||||
`Maximum nesting depth of ${maxDepth} exceeded while validating. ` +
|
||||
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||
);
|
||||
}
|
||||
|
||||
// A cycle has already been validated further up the stack; re-entering it would never
|
||||
// terminate. Diamonds are still validated on each distinct path.
|
||||
if (ancestors.has(obj)) return errors;
|
||||
@@ -339,7 +488,7 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
try {
|
||||
if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
const childErrors = await validateInternal(obj[i], ancestors);
|
||||
const childErrors = await validateInternal(obj[i], ancestors, depth + 1, maxDepth);
|
||||
if (childErrors.length > 0) {
|
||||
errors.push({
|
||||
property: `[${i}]`,
|
||||
@@ -355,32 +504,26 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
const target = prototypeOf(obj);
|
||||
if (!target) return errors;
|
||||
|
||||
const properties: string[] = metadataStorage.getProperties(target);
|
||||
|
||||
for (const key of properties) {
|
||||
for (const property of validationPlan(target)) {
|
||||
const key = property.key;
|
||||
const value = obj[key];
|
||||
|
||||
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
|
||||
if (property.condition && !property.condition(obj)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle IsOptional
|
||||
if (property.isOptional && (value === null || value === undefined)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const propertyErrors: ValidationError = {
|
||||
property: key,
|
||||
value: value,
|
||||
constraints: {}
|
||||
};
|
||||
|
||||
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
|
||||
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
|
||||
if (condition && !condition(obj)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle IsOptional
|
||||
const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key);
|
||||
const isNullOrUndefined = value === null || value === undefined;
|
||||
|
||||
if (isOptional && isNullOrUndefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check validation constraints
|
||||
const constraints = collectConstraints(target, key);
|
||||
const validationArgs: ValidationArguments = {
|
||||
value: value,
|
||||
object: obj,
|
||||
@@ -388,32 +531,41 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
constraints: []
|
||||
};
|
||||
|
||||
for (const constraint of constraints) {
|
||||
for (const constraint of property.constraints) {
|
||||
validationArgs.constraints = constraint.constraints || [];
|
||||
|
||||
let isValid = true;
|
||||
let failedIndex = -1;
|
||||
if (constraint.each && Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const itemArgs = { ...validationArgs, value: item };
|
||||
if (!(await constraint.validate(item, itemArgs))) {
|
||||
// Report which element failed. Previously the index was discarded, so a bad entry
|
||||
// in a 200-item array produced a message that could not locate it.
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
validationArgs.value = value[i];
|
||||
if (!(await constraint.validate(value[i], validationArgs))) {
|
||||
isValid = false;
|
||||
failedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
validationArgs.value = value;
|
||||
} else {
|
||||
isValid = await constraint.validate(value, validationArgs);
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
if (failedIndex >= 0) validationArgs.value = value[failedIndex];
|
||||
let message = typeof constraint.message === 'function'
|
||||
? constraint.message(validationArgs)
|
||||
: constraint.message;
|
||||
validationArgs.value = value;
|
||||
|
||||
// Only decorate the library's own default wording. A message the caller wrote
|
||||
// is reported verbatim — prefixing it produced sentences like
|
||||
// "each element in tags must all be strings".
|
||||
if (constraint.each && !constraint.hasCustomMessage) {
|
||||
message = `each element in ${message}`;
|
||||
message = failedIndex >= 0
|
||||
? `each element in ${message} (failed at index ${failedIndex})`
|
||||
: `each element in ${message}`;
|
||||
}
|
||||
|
||||
recordFailure(propertyErrors.constraints, constraint.name, message);
|
||||
@@ -421,9 +573,8 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
}
|
||||
|
||||
// Recursive validation
|
||||
const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key);
|
||||
if (isNested && value !== null && value !== undefined) {
|
||||
const nestedErrors = await validateInternal(value, ancestors);
|
||||
if (property.isNested && value !== null && value !== undefined) {
|
||||
const nestedErrors = await validateInternal(value, ancestors, depth + 1, maxDepth);
|
||||
if (nestedErrors.length > 0) {
|
||||
propertyErrors.children = nestedErrors;
|
||||
}
|
||||
@@ -442,7 +593,11 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
|
||||
function serializeContext(options?: TransformOptions): SerializeContext {
|
||||
const resolved = resolveOptions(options);
|
||||
return { naming: resolveNamingStrategy(resolved.namingStrategy) };
|
||||
return {
|
||||
naming: resolveNamingStrategy(resolved.namingStrategy),
|
||||
namingKey: resolved.namingStrategy,
|
||||
maxDepth: resolved.maxDepth,
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeContext(options?: TransformOptions): DeserializeContext {
|
||||
@@ -451,6 +606,7 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
|
||||
naming: resolveNamingStrategy(resolved.namingStrategy),
|
||||
namingKey: resolved.namingStrategy,
|
||||
unknownKeys: resolved.unknownKeys,
|
||||
maxDepth: resolved.maxDepth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,8 +617,8 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
|
||||
* @param obj The object to validate
|
||||
* @returns Array of validation errors
|
||||
*/
|
||||
export async function validate(obj: any): Promise<ValidationError[]> {
|
||||
return validateInternal(obj, new Set());
|
||||
export async function validate(obj: any, options?: TransformOptions): Promise<ValidationError[]> {
|
||||
return validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -492,13 +648,13 @@ export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<an
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
|
||||
if (resolveOptions(options).validate) {
|
||||
const errors = await validate(obj);
|
||||
const errors = await validate(obj, options);
|
||||
if (errors.length > 0) {
|
||||
throw new JsonValidationError('Validation failed during serialization', errors);
|
||||
}
|
||||
}
|
||||
|
||||
return serialize(obj, new Set(), serializeContext(options));
|
||||
return serialize(obj, new Set(), serializeContext(options), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -524,10 +680,10 @@ export async function toJson<T>(obj: T, options?: TransformOptions): Promise<str
|
||||
* @returns Class instance
|
||||
*/
|
||||
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
|
||||
const instance = await deserialize(clazz, plain, deserializeContext(options));
|
||||
const instance = await deserialize(clazz, plain, deserializeContext(options), 0);
|
||||
|
||||
if (resolveOptions(options).validate) {
|
||||
const errors = await validate(instance);
|
||||
const errors = await validate(instance, options);
|
||||
if (errors.length > 0) {
|
||||
throw new JsonValidationError('Validation failed during deserialization', errors);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user