🐛 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
+54 -7
View File
@@ -9,6 +9,7 @@ export const METADATA_KEYS = {
DESERIALIZER: 'cereale:deserializer', DESERIALIZER: 'cereale:deserializer',
POLYMORPHIC: 'cereale:polymorphic', POLYMORPHIC: 'cereale:polymorphic',
IS_OPTIONAL: 'cereale:optional', IS_OPTIONAL: 'cereale:optional',
NESTED: 'cereale:nested',
}; };
export interface ValidationArguments { export interface ValidationArguments {
@@ -29,6 +30,12 @@ export type ValidationConstraint = {
message: string | ((args: ValidationArguments) => string); message: string | ((args: ValidationArguments) => string);
constraints?: any[]; constraints?: any[];
each?: boolean; each?: boolean;
/**
* True when the message came from the user via `ValidationOptions.message`.
* The engine only decorates default messages with the "each element in ..." prefix;
* a message the user wrote is reported exactly as written.
*/
hasCustomMessage?: boolean;
}; };
export interface ValidatorConstraintInterface { export interface ValidatorConstraintInterface {
@@ -55,6 +62,7 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
} }
if (options.message) { if (options.message) {
constraint.message = options.message; constraint.message = options.message;
constraint.hasCustomMessage = true;
} }
} }
@@ -98,14 +106,34 @@ export function JsonType(typeFunction: () => ClassConstructor<any>) {
}; };
} }
export interface PolymorphicOptions {
/**
* What to do when the discriminator value matches no registered subtype.
* - `keep` (default): pass the raw value through untouched.
* - `error`: throw a {@link JsonMappingError} naming the unknown discriminator value.
*/
onUnknown?: 'keep' | 'error';
/** Subtype to use when the discriminator matches nothing. Takes precedence over `onUnknown`. */
fallback?: ClassConstructor<any>;
}
/** /**
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) * @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[], options?: PolymorphicOptions)
* Defines polymorphic behavior for a property. * Defines polymorphic behavior for a property.
*/ */
export function JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) { export function JsonPolymorphic(
discriminator: string,
subTypes: { value: ClassConstructor<any>, name: string }[],
options?: PolymorphicOptions
) {
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey); registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.POLYMORPHIC, { discriminator, subTypes }, target, propertyKey); metadataStorage.defineMetadata(
METADATA_KEYS.POLYMORPHIC,
{ discriminator, subTypes, onUnknown: options?.onUnknown ?? 'keep', fallback: options?.fallback },
target,
propertyKey
);
}; };
} }
@@ -333,10 +361,16 @@ export function IsUrl(options?: ValidationOptions) {
* @Matches(pattern: RegExp) * @Matches(pattern: RegExp)
*/ */
export function Matches(pattern: RegExp, options?: ValidationOptions) { export function Matches(pattern: RegExp, options?: ValidationOptions) {
// A `g` or `y` flag makes RegExp.prototype.test stateful: it advances lastIndex on a
// match and resumes from there on the next call, so validating the same value twice
// yields different answers. Validation must be a pure predicate, so drop those flags.
const stateless = pattern.flags.includes('g') || pattern.flags.includes('y')
? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
: pattern;
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, { addValidation(target, propertyKey, {
name: 'matches', name: 'matches',
validate: (v) => typeof v === 'string' && pattern.test(v), validate: (v) => typeof v === 'string' && stateless.test(v),
message: `${propertyKey} must match ${pattern} regular expression`, message: `${propertyKey} must match ${pattern} regular expression`,
constraints: [pattern] constraints: [pattern]
}, options); }, options);
@@ -439,13 +473,26 @@ export function IsDate(options?: ValidationOptions) {
} }
/** /**
* @ValidateNested() * @ValidateNested(options?: ValidationOptions)
* Recursively validates the value of this property.
*
* `{ each: true }` documents that the property holds a collection; nested validation
* already recurses into arrays, but passing `each` additionally asserts that the value
* really is an array.
*/ */
export function ValidateNested() { export function ValidateNested(options?: ValidationOptions) {
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey); registerProperty(target, propertyKey);
// This is a marker for recursive validation // This is a marker for recursive validation
metadataStorage.defineMetadata('cereale:nested', true, target, propertyKey); metadataStorage.defineMetadata(METADATA_KEYS.NESTED, true, target, propertyKey);
if (options?.each) {
addValidation(target, propertyKey, {
name: 'nestedEach',
validate: (v) => Array.isArray(v),
message: `${propertyKey} must be an array`
});
}
}; };
} }
+23
View File
@@ -63,6 +63,29 @@ export class MetadataStorage {
return undefined; return undefined;
} }
/**
* Collects a metadata value from every level of the prototype chain that defines one.
*
* Unlike {@link getMetadata}, which stops at the first (most derived) match, this returns
* every value found, ordered from the BASE class down to the most derived one. It exists
* for metadata that must accumulate across an inheritance chain rather than be overridden —
* validation constraints in particular, where a subclass re-decorating an inherited property
* must add to the base class's rules instead of silently replacing them.
*/
getMetadataChain(key: string, target: any, propertyKey?: string): any[] {
const chain: any[] = [];
let current = target;
while (current) {
const value = this.getOwnMetadata(key, current, propertyKey);
if (value !== undefined) {
// Walking derived -> base, so prepend to end up base-first.
chain.unshift(value);
}
current = Object.getPrototypeOf(current);
}
return chain;
}
/** /**
* Gets metadata defined directly on the target. * Gets metadata defined directly on the target.
*/ */
+447
View File
@@ -0,0 +1,447 @@
import { describe, it, expect } from 'vitest';
import {
IsString, IsInt, Min, MinLength, Matches, IsIn, ValidateNested, JsonType,
JsonPolymorphic, JsonSerialize, JsonSerializer, JsonMappingError,
toInstance, toInstanceArray, toPlain, toJson, fromJson, fromJsonArray, fromRequest, validate,
} from './index.js';
/**
* Each block here pins down a defect that the engine used to have. The comment above the
* block describes the old, wrong behaviour.
*/
describe('regressions', () => {
describe('inheritance', () => {
// Was: a subclass re-decorating an inherited property registered its constraints on its
// own prototype, and the engine read only the nearest set — so every rule the base class
// declared was silently dropped.
it('merges validation constraints across the prototype chain', async () => {
class Base {
@MinLength(5)
name: string;
}
class Sub extends Base {
@IsString()
declare name: string;
}
const s = new Sub();
s.name = 'ab'; // satisfies Sub's @IsString, violates Base's @MinLength(5)
const errors = await validate(s);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints).toHaveProperty('minLength');
});
it('enforces base constraints that the subclass never restates', async () => {
abstract class Media {
@IsString()
title: string;
}
class Book extends Media {
@IsString()
author: string;
}
const b = new Book();
b.title = 42 as any;
b.author = 'Fitzgerald';
const errors = await validate(b);
expect(errors.map(e => e.property)).toContain('title');
});
it('does not report an identical inherited rule twice', async () => {
class Base {
@IsString()
type: string;
}
class Sub extends Base {
@IsString()
declare type: string;
}
const s = new Sub();
s.type = 1 as any;
const errors = await validate(s);
expect(errors).toHaveLength(1);
expect(Object.keys(errors[0]!.constraints)).toEqual(['isString']);
});
});
describe('cycles', () => {
// Was: serialize() recursed forever on a cycle, exhausting an 8 GB heap and killing the
// process. A clear error beats an OOM.
it('reports a circular reference instead of exhausting the heap', async () => {
class Node {
@IsString()
name: string;
next?: any;
}
const a = new Node();
a.name = 'a';
a.next = a;
await expect(toPlain(a)).rejects.toThrow(JsonMappingError);
await expect(toPlain(a)).rejects.toThrow(/Circular reference/);
});
it('still serializes a diamond, where one object is referenced twice', async () => {
class Leaf {
@IsString()
id: string;
}
class Holder {
left: Leaf;
right: Leaf;
}
const shared = new Leaf();
shared.id = 'shared';
const h = new Holder();
h.left = shared;
h.right = shared;
const plain = await toPlain(h);
expect(plain).toEqual({ left: { id: 'shared' }, right: { id: 'shared' } });
});
it('terminates when validating a cyclic @ValidateNested graph', async () => {
class Person {
@IsString()
name: string;
@ValidateNested()
friend?: Person;
}
const a = new Person();
a.name = 'a';
const b = new Person();
b.name = 'b';
a.friend = b;
b.friend = a;
await expect(validate(a)).resolves.toEqual([]);
});
});
describe('messages', () => {
// Was: the "each element in ..." prefix was glued onto every message, including ones the
// caller wrote, producing "each element in tags must all be strings".
it('reports a caller-supplied message verbatim under each:true', async () => {
class T {
@IsString({ each: true, message: 'tags must all be strings' })
tags: any[];
}
const t = new T();
t.tags = [1];
const errors = await validate(t);
expect(errors[0]!.constraints['isString']).toBe('tags must all be strings');
});
it('still prefixes the library default message under each:true', async () => {
class T {
@IsString({ each: true })
tags: any[];
}
const t = new T();
t.tags = [1];
const errors = await validate(t);
expect(errors[0]!.constraints['isString']).toContain('each element in');
});
// Was: two constraints sharing a name overwrote each other in the error record, so only
// the last failure was ever reported.
it('keeps every failure when two rules share a name', async () => {
class T {
@Min(10)
@Min(5)
n: number;
}
const t = new T();
t.n = 1;
const errors = await validate(t);
const messages = Object.values(errors[0]!.constraints);
expect(messages).toHaveLength(2);
expect(messages).toEqual(expect.arrayContaining([
'n must be at least 5',
'n must be at least 10',
]));
});
});
describe('@Matches', () => {
// Was: a /g regex kept its lastIndex between calls, so validating the same value twice
// gave different answers — the second call spuriously failed.
it('is stateless when the pattern carries a g flag', async () => {
class T {
@Matches(/^[a-z]+$/g)
v: string;
}
const t = new T();
t.v = 'abc';
expect(await validate(t)).toHaveLength(0);
expect(await validate(t)).toHaveLength(0);
expect(await validate(t)).toHaveLength(0);
});
it('is stateless when the pattern carries a y flag', async () => {
class T {
@Matches(/^[a-z]+$/y)
v: string;
}
const t = new T();
t.v = 'abc';
expect(await validate(t)).toHaveLength(0);
expect(await validate(t)).toHaveLength(0);
});
});
describe('@JsonPolymorphic', () => {
abstract class Animal {
@IsString()
type: string;
}
class Dog extends Animal {
@IsString()
breed: string;
}
// Was: when the discriminator matched no subtype, the single-object branch fell through
// without assigning anything, so the property came back `undefined` and the caller's data
// vanished without a word.
it('keeps the raw value when the discriminator matches nothing', async () => {
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
pet: Animal;
}
const h = await toInstance(Holder, { pet: { type: 'cat', sound: 'meow' } });
expect(h.pet).toBeDefined();
expect(h.pet).toEqual({ type: 'cat', sound: 'meow' });
});
it('can be told to reject an unknown discriminator instead', async () => {
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { onUnknown: 'error' })
pet: Animal;
}
await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(JsonMappingError);
await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(/Unknown discriminator/);
});
it('can fall back to a default subtype', async () => {
class Unknown extends Animal {
@IsString()
override type = 'unknown';
}
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { fallback: Unknown })
pet: Animal;
}
const h = await toInstance(Holder, { pet: { type: 'cat' } });
expect(h.pet).toBeInstanceOf(Unknown);
});
it('keeps unmatched entries inside an array', async () => {
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
pets: Animal[];
}
const h = await toInstance(Holder, {
pets: [{ type: 'dog', breed: 'Lab' }, { type: 'cat', sound: 'meow' }],
});
expect(h.pets[0]).toBeInstanceOf(Dog);
expect(h.pets[1]).toEqual({ type: 'cat', sound: 'meow' });
});
});
describe('prototype handling', () => {
// Was: serialize() read `obj.constructor.prototype`, which throws for an object created
// with a null prototype because it has no `constructor`.
it('serializes a null-prototype object', async () => {
const o = Object.create(null);
o.a = 1;
o.b = { c: 2 };
await expect(toPlain(o)).resolves.toEqual({ a: 1, b: { c: 2 } });
});
// Was: `__proto__` arriving in a JSON body was copied straight onto the instance, which
// swaps the instance's prototype and detaches it from its own class.
it('drops __proto__ from untrusted input', async () => {
class Dto {
@IsString()
name: string;
}
const malicious = JSON.parse('{"name":"x","__proto__":{"polluted":"yes"}}');
const dto = await toInstance(Dto, malicious);
expect(dto).toBeInstanceOf(Dto);
expect(Object.getPrototypeOf(dto)).toBe(Dto.prototype);
expect(({} as any).polluted).toBeUndefined();
});
it('drops constructor and prototype keys from untrusted input', async () => {
class Dto {
@IsString()
name: string;
}
const dto = await toInstance(Dto, JSON.parse('{"name":"x","constructor":1,"prototype":2}'));
expect(dto.constructor).toBe(Dto);
expect((dto as any).prototype).toBeUndefined();
});
});
describe('custom serializers', () => {
// Was: a @JsonSerialize serializer was invoked even when the property was null or
// undefined, so any serializer that touched the value crashed on an unset optional field.
it('is skipped for an unset optional property', async () => {
class IsoDate implements JsonSerializer<Date, string> {
serialize(value: Date): string {
return value.toISOString();
}
}
class T {
@JsonSerialize(IsoDate)
when?: Date | undefined;
@IsString()
other: string;
}
const t = new T();
t.other = 'x';
t.when = undefined;
await expect(toPlain(t)).resolves.toEqual({ when: undefined, other: 'x' });
});
it('still runs for a property that has a value', async () => {
class IsoDate implements JsonSerializer<Date, string> {
serialize(value: Date): string {
return value.toISOString().slice(0, 10);
}
}
class T {
@JsonSerialize(IsoDate)
when: Date;
}
const t = new T();
t.when = new Date('1925-04-10T00:00:00Z');
await expect(toJson(t)).resolves.toBe('{"when":"1925-04-10"}');
});
});
describe('array entry points', () => {
class Item {
@IsString()
name: string;
}
// Was: `toInstance`/`fromJson` accepted arrays at runtime but typed the result as `T`,
// so consumers had to cast to reach the elements.
it('toInstanceArray returns a correctly typed array', async () => {
const items = await toInstanceArray(Item, [{ name: 'a' }, { name: 'b' }]);
expect(items).toHaveLength(2);
expect(items[0]).toBeInstanceOf(Item);
expect(items[0]!.name).toBe('a');
});
it('fromJsonArray parses and validates a JSON array', async () => {
const items = await fromJsonArray(Item, '[{"name":"a"}]');
expect(items[0]!.name).toBe('a');
});
it('toInstanceArray rejects a non-array payload', async () => {
await expect(toInstanceArray(Item, {} as any)).rejects.toThrow(JsonMappingError);
});
it('fromJson still accepts an array for backwards compatibility', async () => {
const items = (await fromJson(Item, '[{"name":"a"}]')) as unknown as Item[];
expect(Array.isArray(items)).toBe(true);
});
});
describe('fromRequest', () => {
it('reports a non-JSON body as a mapping error', async () => {
class Dto {
@IsString()
name: string;
}
const request = new Request('https://example.com', { method: 'POST', body: 'not json' });
await expect(fromRequest(Dto, request)).rejects.toThrow(JsonMappingError);
await expect(
fromRequest(Dto, new Request('https://example.com', { method: 'POST', body: '' }))
).rejects.toThrow(/not valid JSON/);
});
});
describe('@ValidateNested', () => {
it('accepts the documented { each: true } option', async () => {
class Item {
@IsInt()
@Min(1)
qty: number;
}
class Order {
@ValidateNested({ each: true })
@JsonType(() => Item)
items: Item[];
}
const bad = new Item();
bad.qty = -5;
const o = new Order();
o.items = [bad];
const errors = await validate(o);
expect(errors).toHaveLength(1);
expect(errors[0]!.children?.[0]?.children?.[0]?.property).toBe('qty');
});
it('{ each: true } asserts the value really is an array', async () => {
class Item {
@IsInt()
qty: number;
}
class Order {
@ValidateNested({ each: true })
items: Item[];
}
const o = new Order();
o.items = 'nope' as any;
const errors = await validate(o);
expect(errors[0]!.constraints).toHaveProperty('nestedEach');
});
});
describe('@IsIn with each:true', () => {
it('rejects a non-array value rather than passing it through', async () => {
class T {
@IsIn(['a', 'b'], { each: true })
tags: any;
}
const t = new T();
t.tags = 'not-allowed';
expect(await validate(t)).toHaveLength(1);
});
});
});
+194 -38
View File
@@ -20,40 +20,87 @@ 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 --- // --- 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') { if (obj === null || obj === undefined || typeof obj !== 'object') {
return obj; return obj;
} }
if (Array.isArray(obj)) {
return Promise.all(obj.map(item => serialize(item)));
}
if (obj instanceof Date) { if (obj instanceof Date) {
return obj.toISOString(); return obj.toISOString();
} }
const target = obj.constructor.prototype; 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.'
);
}
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 = {}; const result: any = {};
const allKeys = Object.keys(obj); for (const key of Object.keys(obj)) {
for (const key of allKeys) {
const value = obj[key]; const value = obj[key];
// Check for custom serializer // Custom serializers only see real values. Handing a serializer `undefined` for a
const serializerCls = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key); // property that was simply never set turns an optional field into a crash.
if (serializerCls) { const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
if (serializerCls && value !== null && value !== undefined) {
const serializer = new serializerCls(); const serializer = new serializerCls();
result[key] = await serializer.serialize(value); result[key] = await serializer.serialize(value);
} else { } else {
result[key] = await serialize(value); result[key] = await serialize(value, ancestors);
} }
} }
return result; 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> { async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
@@ -64,11 +111,15 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
return results as any; return results as any;
} }
if (typeof plain !== 'object') return plain;
const instance = new clazz(); const instance = new clazz();
const target = clazz.prototype; const target = clazz.prototype;
// Copy all properties from plain to instance // Copy all properties from plain to instance
for (const key of Object.keys(plain)) { for (const key of Object.keys(plain)) {
if (FORBIDDEN_KEYS.has(key)) continue;
const value = plain[key]; const value = plain[key];
// Custom Deserializer // Custom Deserializer
@@ -82,19 +133,26 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
// Polymorphic // Polymorphic
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key); const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
if (poly && value !== null && value !== undefined) { if (poly && value !== null && value !== undefined) {
const { discriminator, subTypes } = poly; const { discriminator, subTypes, onUnknown, fallback } = poly;
if (Array.isArray(value)) {
instance[key as keyof T] = await Promise.all(value.map(async item => { 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); const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
return subTypeInfo ? deserialize(subTypeInfo.value, item) : item; if (subTypeInfo) return deserialize(subTypeInfo.value, item);
})) as any; if (fallback) return deserialize(fallback, item);
} else { if (onUnknown === 'error') {
const subTypeInfo = subTypes.find((s: any) => value[discriminator] === s.name); throw new JsonMappingError(
if (subTypeInfo) { `Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
instance[key as keyof T] = await deserialize(subTypeInfo.value, value); `"${key}". Known values: ${subTypes.map((s: any) => JSON.stringify(s.name)).join(', ')}.`
continue; );
}
} }
// 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; continue;
} }
@@ -112,20 +170,63 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
return instance; return instance;
} }
// --- Public API Functions --- /**
* 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;
}
/** /**
* Validates a class instance or object against its decorators. * Records a failure without letting a later constraint overwrite an earlier one that happens
* @param obj The object to validate * to share a name (two `@Min` rules, or a rule inherited and re-declared).
* @returns Array of validation errors
*/ */
export async function validate(obj: any): Promise<ValidationError[]> { 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[] = []; const errors: ValidationError[] = [];
if (obj === null || obj === undefined || typeof obj !== 'object') return errors; 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)) { if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) { for (let i = 0; i < obj.length; i++) {
const childErrors = await validate(obj[i]); const childErrors = await validateInternal(obj[i], ancestors);
if (childErrors.length > 0) { if (childErrors.length > 0) {
errors.push({ errors.push({
property: `[${i}]`, property: `[${i}]`,
@@ -138,7 +239,9 @@ export async function validate(obj: any): Promise<ValidationError[]> {
return errors; return errors;
} }
const target = Object.getPrototypeOf(obj); const target = prototypeOf(obj);
if (!target) return errors;
const properties: string[] = metadataStorage.getProperties(target); const properties: string[] = metadataStorage.getProperties(target);
for (const key of properties) { for (const key of properties) {
@@ -158,7 +261,7 @@ export async function validate(obj: any): Promise<ValidationError[]> {
} }
// Check validation constraints // Check validation constraints
const constraints: ValidationConstraint[] = metadataStorage.getMetadata(METADATA_KEYS.VALIDATION, target, key) || []; const constraints = collectConstraints(target, key);
const validationArgs: ValidationArguments = { const validationArgs: ValidationArguments = {
value: value, value: value,
object: obj, object: obj,
@@ -187,18 +290,21 @@ export async function validate(obj: any): Promise<ValidationError[]> {
? constraint.message(validationArgs) ? constraint.message(validationArgs)
: constraint.message; : constraint.message;
if (constraint.each) { // 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 = `each element in ${message}`;
} }
propertyErrors.constraints[constraint.name] = message; recordFailure(propertyErrors.constraints, constraint.name, message);
} }
} }
// Recursive validation // Recursive validation
const isNested = metadataStorage.getMetadata('cereale:nested', target, key); const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key);
if (isNested && value !== null && value !== undefined) { if (isNested && value !== null && value !== undefined) {
const nestedErrors = await validate(value); const nestedErrors = await validateInternal(value, ancestors);
if (nestedErrors.length > 0) { if (nestedErrors.length > 0) {
propertyErrors.children = nestedErrors; propertyErrors.children = nestedErrors;
} }
@@ -210,6 +316,20 @@ export async function validate(obj: any): Promise<ValidationError[]> {
} }
return errors; return errors;
} finally {
ancestors.delete(obj);
}
}
// --- Public API Functions ---
/**
* Validates a class instance or object against its decorators.
* @param obj The object to validate
* @returns Array of validation errors
*/
export async function validate(obj: any): Promise<ValidationError[]> {
return validateInternal(obj, new Set());
} }
/** /**
@@ -225,7 +345,7 @@ export async function toPlain<T>(obj: T): Promise<any> {
throw new JsonValidationError('Validation failed during serialization', errors); throw new JsonValidationError('Validation failed during serialization', errors);
} }
return serialize(obj); return serialize(obj, new Set());
} }
/** /**
@@ -255,6 +375,23 @@ export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any): Pro
return instance; 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. * Parses a JSON string to a class instance with validation.
* @param clazz The class constructor * @param clazz The class constructor
@@ -266,6 +403,16 @@ export async function fromJson<T>(clazz: ClassConstructor<T>, json: string): Pro
return toInstance(clazz, plain); 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.) * Helper for Fetch-based frameworks (Next.js, Hono, etc.)
* Extracts JSON from a Request and transforms it to a validated instance. * 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 * @returns Validated class instance
*/ */
export async function fromRequest<T>(clazz: ClassConstructor<T>, request: Request): Promise<T> { 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); return toInstance(clazz, plain);
} }
@@ -285,7 +439,9 @@ export class JsonMapper {
static toPlain = toPlain; static toPlain = toPlain;
static toJson = toJson; static toJson = toJson;
static toInstance = toInstance; static toInstance = toInstance;
static toInstanceArray = toInstanceArray;
static fromJson = fromJson; static fromJson = fromJson;
static fromJsonArray = fromJsonArray;
static fromRequest = fromRequest; static fromRequest = fromRequest;
static validate = validate; static validate = validate;
} }