🐛 fix: repair eight correctness defects in the mapping and validation engines

Each fix is pinned by a regression test in src/regressions.test.ts describing the
old behaviour.

- Inheritance dropped base-class rules. A subclass re-decorating an inherited
  property registered its constraints against its own prototype, and validate()
  read only the nearest set, so everything the base declared was silently lost.
  Constraints are now merged down the whole prototype chain, base first, with
  genuinely identical rules collapsed so restating @IsString() on an override
  does not double-report. The library's own example.ts was affected: Media's
  @IsString() title had never been enforced for Book.
- Circular references exhausted the heap. serialize() recursed forever, taking
  8 GB and the process with it; it now tracks ancestors and raises a
  JsonMappingError naming the cause. Diamonds still serialize. validate() skips
  back-edges instead of recursing.
- @Matches with a g or y flag was stateful: RegExp.test advances lastIndex, so
  validating the same value twice gave different answers. Those flags are
  stripped.
- An unmatched @JsonPolymorphic discriminator silently dropped the value — the
  single-object branch fell through without assigning. The raw value is now
  preserved, with { onUnknown: 'error' } and { fallback } to choose otherwise.
- Custom @JsonSerialize serializers ran on null/undefined, crashing on any unset
  optional property. They now only see real values.
- serialize() read obj.constructor.prototype, which throws for null-prototype
  objects; both engines now agree on Object.getPrototypeOf.
- __proto__, constructor and prototype arriving in untrusted JSON were copied
  onto the instance, detaching it from its own class. They are dropped.
- The "each element in ..." prefix was glued onto caller-supplied messages, and
  two rules sharing a name overwrote each other so only one failure surfaced.

Also adds toInstanceArray/fromJsonArray, since toInstance and fromJson accept
arrays at runtime but type the result as T, and reports a non-JSON request body
in fromRequest as a JsonMappingError rather than a raw SyntaxError.

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-03 23:41:10 +00:00
parent 66e19f690f
commit 6d04b43964
4 changed files with 811 additions and 138 deletions
+286 -130
View File
@@ -20,55 +20,106 @@ export class JsonValidationError extends Error {
}
}
/**
* Thrown when a value cannot be mapped at all — as opposed to mapping fine but failing
* validation, which raises {@link JsonValidationError}.
*/
export class JsonMappingError extends Error {
constructor(message: string) {
super(message);
this.name = 'JsonMappingError';
}
}
/**
* Keys that must never be copied from untrusted input onto an instance. Assigning
* `__proto__` swaps an object's prototype, and `constructor` / `prototype` are the usual
* next steps in a pollution chain. This library exists to parse request bodies, so the
* transform layer drops them rather than trusting callers to sanitise first.
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
// --- Internal Engine ---
async function serialize(obj: any): Promise<any> {
/**
* Resolves the metadata lookup target for a value.
*
* `Object.getPrototypeOf` rather than `obj.constructor.prototype`: the latter throws on
* null-prototype objects (which have no `constructor`) and lies for instances whose
* `constructor` property has been overwritten.
*/
function prototypeOf(obj: any): any {
return Object.getPrototypeOf(obj) ?? undefined;
}
async function serialize(obj: any, ancestors: Set<any>): Promise<any> {
if (obj === null || obj === undefined || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return Promise.all(obj.map(item => serialize(item)));
}
if (obj instanceof Date) {
return obj.toISOString();
}
const target = obj.constructor.prototype;
const result: any = {};
const allKeys = Object.keys(obj);
for (const key of allKeys) {
const value = obj[key];
// Check for custom serializer
const serializerCls = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
if (serializerCls) {
const serializer = new serializerCls();
result[key] = await serializer.serialize(value);
} else {
result[key] = await serialize(value);
}
if (ancestors.has(obj)) {
throw new JsonMappingError(
'Circular reference detected during serialization. Break the cycle with @JsonIgnore() ' +
'on the back-reference, or supply a @JsonSerialize() serializer for that property.'
);
}
return result;
ancestors.add(obj);
try {
if (Array.isArray(obj)) {
const out: any[] = [];
for (const item of obj) {
out.push(await serialize(item, ancestors));
}
return out;
}
const target = prototypeOf(obj);
const result: any = {};
for (const key of Object.keys(obj)) {
const value = obj[key];
// 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[key] = await serializer.serialize(value);
} else {
result[key] = await serialize(value, ancestors);
}
}
return result;
} finally {
// Only direct ancestors count as a cycle; the same object appearing twice in
// sibling positions (a diamond) is perfectly serializable.
ancestors.delete(obj);
}
}
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
if (plain === null || plain === undefined) return plain;
if (Array.isArray(plain)) {
const results = await Promise.all(plain.map(item => deserialize(clazz, item)));
return results as any;
}
if (typeof plain !== 'object') return plain;
const instance = new clazz();
const target = clazz.prototype;
// Copy all properties from plain to instance
for (const key of Object.keys(plain)) {
if (FORBIDDEN_KEYS.has(key)) continue;
const value = plain[key];
// Custom Deserializer
@@ -82,19 +133,26 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
// Polymorphic
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
if (poly && value !== null && value !== undefined) {
const { discriminator, subTypes } = poly;
if (Array.isArray(value)) {
instance[key as keyof T] = await Promise.all(value.map(async item => {
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
return subTypeInfo ? deserialize(subTypeInfo.value, item) : item;
})) as any;
} else {
const subTypeInfo = subTypes.find((s: any) => value[discriminator] === s.name);
if (subTypeInfo) {
instance[key as keyof T] = await deserialize(subTypeInfo.value, value);
continue;
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);
if (fallback) return deserialize(fallback, item);
if (onUnknown === 'error') {
throw new JsonMappingError(
`Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
`"${key}". Known values: ${subTypes.map((s: any) => JSON.stringify(s.name)).join(', ')}.`
);
}
}
// Preserve the raw value. Dropping it silently loses data the caller sent.
return item;
};
instance[key as keyof T] = Array.isArray(value)
? (await Promise.all(value.map(resolve))) as any
: await resolve(value);
continue;
}
@@ -112,6 +170,157 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
return instance;
}
/**
* Collects the validation constraints that apply to a property, merged across the whole
* prototype chain.
*
* A subclass that re-decorates an inherited property registers its constraints against its
* own prototype. Reading only the nearest set would silently drop everything the base class
* declared, so the chain is flattened base-first. Constraints that are genuinely identical
* (same rule, same fixed message) are collapsed so that re-stating `@IsString()` on an
* override does not report the same failure twice; anything with a computed message — custom
* validators in particular — is always kept.
*/
function collectConstraints(target: any, key: string): ValidationConstraint[] {
const levels: ValidationConstraint[][] = metadataStorage.getMetadataChain(METADATA_KEYS.VALIDATION, target, key);
const merged: ValidationConstraint[] = [];
const seen = new Set<string>();
for (const level of levels) {
for (const constraint of level) {
if (typeof constraint.message === 'string') {
const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}`;
if (seen.has(identity)) continue;
seen.add(identity);
}
merged.push(constraint);
}
}
return merged;
}
/**
* 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).
*/
function recordFailure(constraints: { [key: string]: string }, name: string, message: string) {
if (!(name in constraints)) {
constraints[name] = message;
return;
}
let suffix = 2;
while (`${name}_${suffix}` in constraints) suffix++;
constraints[`${name}_${suffix}`] = message;
}
async function validateInternal(obj: any, ancestors: Set<any>): Promise<ValidationError[]> {
const errors: ValidationError[] = [];
if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
// 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;
ancestors.add(obj);
try {
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const childErrors = await validateInternal(obj[i], ancestors);
if (childErrors.length > 0) {
errors.push({
property: `[${i}]`,
value: obj[i],
constraints: {},
children: childErrors
});
}
}
return errors;
}
const target = prototypeOf(obj);
if (!target) return errors;
const properties: string[] = metadataStorage.getProperties(target);
for (const key of properties) {
const value = obj[key];
const propertyErrors: ValidationError = {
property: key,
value: value,
constraints: {}
};
// 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,
property: key,
constraints: []
};
for (const constraint of constraints) {
validationArgs.constraints = constraint.constraints || [];
let isValid = true;
if (constraint.each && Array.isArray(value)) {
for (const item of value) {
const itemArgs = { ...validationArgs, value: item };
if (!(await constraint.validate(item, itemArgs))) {
isValid = false;
break;
}
}
} else {
isValid = await constraint.validate(value, validationArgs);
}
if (!isValid) {
let message = typeof constraint.message === 'function'
? constraint.message(validationArgs)
: constraint.message;
// 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}`;
}
recordFailure(propertyErrors.constraints, constraint.name, message);
}
}
// Recursive validation
const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key);
if (isNested && value !== null && value !== undefined) {
const nestedErrors = await validateInternal(value, ancestors);
if (nestedErrors.length > 0) {
propertyErrors.children = nestedErrors;
}
}
if (Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) {
errors.push(propertyErrors);
}
}
return errors;
} finally {
ancestors.delete(obj);
}
}
// --- Public API Functions ---
/**
@@ -120,96 +329,7 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
* @returns Array of validation errors
*/
export async function validate(obj: any): Promise<ValidationError[]> {
const errors: ValidationError[] = [];
if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const childErrors = await validate(obj[i]);
if (childErrors.length > 0) {
errors.push({
property: `[${i}]`,
value: obj[i],
constraints: {},
children: childErrors
});
}
}
return errors;
}
const target = Object.getPrototypeOf(obj);
const properties: string[] = metadataStorage.getProperties(target);
for (const key of properties) {
const value = obj[key];
const propertyErrors: ValidationError = {
property: key,
value: value,
constraints: {}
};
// 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: ValidationConstraint[] = metadataStorage.getMetadata(METADATA_KEYS.VALIDATION, target, key) || [];
const validationArgs: ValidationArguments = {
value: value,
object: obj,
property: key,
constraints: []
};
for (const constraint of constraints) {
validationArgs.constraints = constraint.constraints || [];
let isValid = true;
if (constraint.each && Array.isArray(value)) {
for (const item of value) {
const itemArgs = { ...validationArgs, value: item };
if (!(await constraint.validate(item, itemArgs))) {
isValid = false;
break;
}
}
} else {
isValid = await constraint.validate(value, validationArgs);
}
if (!isValid) {
let message = typeof constraint.message === 'function'
? constraint.message(validationArgs)
: constraint.message;
if (constraint.each) {
message = `each element in ${message}`;
}
propertyErrors.constraints[constraint.name] = message;
}
}
// Recursive validation
const isNested = metadataStorage.getMetadata('cereale:nested', target, key);
if (isNested && value !== null && value !== undefined) {
const nestedErrors = await validate(value);
if (nestedErrors.length > 0) {
propertyErrors.children = nestedErrors;
}
}
if (Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) {
errors.push(propertyErrors);
}
}
return errors;
return validateInternal(obj, new Set());
}
/**
@@ -219,13 +339,13 @@ export async function validate(obj: any): Promise<ValidationError[]> {
*/
export async function toPlain<T>(obj: T): Promise<any> {
if (obj === null || obj === undefined) return obj;
const errors = await validate(obj);
if (errors.length > 0) {
throw new JsonValidationError('Validation failed during serialization', errors);
}
return serialize(obj);
return serialize(obj, new Set());
}
/**
@@ -246,15 +366,32 @@ export async function toJson<T>(obj: T): Promise<string> {
*/
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
const instance = await deserialize(clazz, plain);
const errors = await validate(instance);
if (errors.length > 0) {
throw new JsonValidationError('Validation failed during deserialization', errors);
}
return instance;
}
/**
* Converts an array of plain objects to an array of class instances with validation.
*
* `toInstance` also accepts arrays at runtime, but its return type says `T`. Use this when
* the payload is a collection so the static type matches what you actually get back.
*
* @param clazz The class constructor
* @param plain The array of plain objects to transform
* @returns Validated array of class instances
*/
export async function toInstanceArray<T>(clazz: ClassConstructor<T>, plain: any[]): Promise<T[]> {
if (!Array.isArray(plain)) {
throw new JsonMappingError(`Expected an array to map to ${clazz.name}[], received ${typeof plain}.`);
}
return (await toInstance(clazz, plain)) as unknown as T[];
}
/**
* Parses a JSON string to a class instance with validation.
* @param clazz The class constructor
@@ -266,6 +403,16 @@ export async function fromJson<T>(clazz: ClassConstructor<T>, json: string): Pro
return toInstance(clazz, plain);
}
/**
* Parses a JSON string containing an array into validated class instances.
* @param clazz The class constructor
* @param json JSON string holding an array
* @returns Validated array of class instances
*/
export async function fromJsonArray<T>(clazz: ClassConstructor<T>, json: string): Promise<T[]> {
return toInstanceArray(clazz, JSON.parse(json));
}
/**
* Helper for Fetch-based frameworks (Next.js, Hono, etc.)
* Extracts JSON from a Request and transforms it to a validated instance.
@@ -274,7 +421,14 @@ export async function fromJson<T>(clazz: ClassConstructor<T>, json: string): Pro
* @returns Validated class instance
*/
export async function fromRequest<T>(clazz: ClassConstructor<T>, request: Request): Promise<T> {
const plain = await request.json();
let plain: any;
try {
plain = await request.json();
} catch (error) {
throw new JsonMappingError(
`Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
);
}
return toInstance(clazz, plain);
}
@@ -285,7 +439,9 @@ export class JsonMapper {
static toPlain = toPlain;
static toJson = toJson;
static toInstance = toInstance;
static toInstanceArray = toInstanceArray;
static fromJson = fromJson;
static fromJsonArray = fromJsonArray;
static fromRequest = fromRequest;
static validate = validate;
}