BREAKING CHANGE: cereale moves from legacy `experimentalDecorators` to TC39
standard decorators, which is what makes validation rules type-checked against
the fields they are attached to.
class User {
@IsString() name!: string; // fine
@IsString() age!: number; // Type 'number' is not assignable to 'string'
}
Legacy decorators receive (target: any, key: string) and lose the field type
entirely, so this was impossible in v1. Standard decorators receive
ClassFieldDecoratorContext<This, Value>, which carries it. Rules now checked:
scalar rules against scalar fields; { each: true } against arrays, in both
directions; @JsonType against the field's class; @JsonSerialize/@JsonDeserialize
against the field's type; @IsIn and @IsEnum against the field's value type.
17 tests invoke the real compiler to assert the wrong code stays rejected — a
guarantee nobody checks is one that quietly stops holding.
Positioning follows the capability: validated domain objects, not validated
data. The README now leads with the Zod comparison. Cereale does not infer your
type from a schema — you still write the field type and the rule — but it
guarantees the two cannot disagree, which is what class-validator never offered.
Removed
- metadata-storage.ts and its WeakMap singleton. Metadata lives on
context.metadata now, which also removes the dual ESM/CJS double-singleton
hazard. Inheritance merging becomes structural rather than reconstructed on
every read, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot
reoccur by construction.
- registerDecorator, replaced by defineRule(Class, 'field', constraint).
Unchanged: the engine, options, naming strategies, access control, error
helpers, the sync API, and the performance work. 193 tests pass.
Toolchain note: standard decorators are transformed by tsc and esbuild, but not
yet by oxc. The library builds with tsc and consumers on esbuild/Vite are fine;
Vitest 4 uses oxc, so the test runner needs an esbuild transform plugin. This is
recorded in vitest.config.ts and the README, and is the reason 1.x should stay
available for oxc-based toolchains.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
1012 lines
34 KiB
TypeScript
1012 lines
34 KiB
TypeScript
import { ClassConstructor } from './interfaces.js';
|
|
import {
|
|
modelOf, modelOfInstance, modelVersion,
|
|
type ClassModel, type PropertyAccess, type PropertyModel,
|
|
type ValidationArguments, type ValidationConstraint,
|
|
} from './metadata.js';
|
|
import { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
|
|
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
|
|
|
|
export interface ValidationError {
|
|
property: string;
|
|
value: any;
|
|
constraints: { [key: string]: string };
|
|
children?: ValidationError[];
|
|
}
|
|
|
|
export class JsonValidationError extends Error {
|
|
constructor(message: string, public errors: ValidationError[]) {
|
|
super(message);
|
|
this.name = 'JsonValidationError';
|
|
}
|
|
|
|
override toString() {
|
|
return `${this.message}: ${JSON.stringify(this.errors, null, 2)}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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']);
|
|
|
|
/**
|
|
* Stands in for the value of a property that is never serialized, so that a failing password
|
|
* does not travel inside a ValidationError into whatever logs the caller writes.
|
|
*/
|
|
export const REDACTED = '[redacted]';
|
|
|
|
// --- Internal Engine ---
|
|
|
|
interface SerializeContext {
|
|
naming: NamingStrategyFn;
|
|
namingKey: unknown;
|
|
maxDepth: number;
|
|
}
|
|
|
|
interface DeserializeContext {
|
|
naming: NamingStrategyFn;
|
|
namingKey: unknown;
|
|
unknownKeys: UnknownKeyPolicy;
|
|
maxDepth: number;
|
|
}
|
|
|
|
function accessOf(model: ClassModel, key: string): PropertyAccess {
|
|
return model[key]?.access ?? 'readwrite';
|
|
}
|
|
|
|
/** The name this property takes in JSON: an explicit @JsonProperty, else the naming strategy. */
|
|
function outboundName(model: ClassModel, key: string, naming: NamingStrategyFn): string {
|
|
return model[key]?.name ?? 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<ClassModel, { 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(model: ClassModel, key: string, ctx: SerializeContext): OutboundProperty {
|
|
let entry = outboundCache.get(model);
|
|
if (!entry || entry.version !== modelVersion()) {
|
|
entry = { version: modelVersion(), byStrategy: new Map() };
|
|
outboundCache.set(model, 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(model, key);
|
|
const serializer = model[key]?.serializer;
|
|
resolved = {
|
|
name: outboundName(model, 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
|
|
* keys — otherwise the default `unknownKeys: 'allow'` policy would copy them straight
|
|
* back onto the instance and undo the protection.
|
|
*/
|
|
blocked: Set<string>;
|
|
}
|
|
|
|
// 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<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
|
|
|
/**
|
|
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
|
*
|
|
* Only names the class actually declares are accepted: the `@JsonProperty` name (or the
|
|
* naming strategy's rendering of the property name) plus any `@JsonAlias`. Renaming a
|
|
* property therefore stops the old name from being silently accepted — add `@JsonAlias` to
|
|
* keep it working for older clients.
|
|
*/
|
|
function inboundNameMap(model: ClassModel, ctx: DeserializeContext): InboundNames {
|
|
let entry = inboundCache.get(model);
|
|
if (!entry || entry.version !== modelVersion()) {
|
|
entry = { version: modelVersion(), byStrategy: new Map() };
|
|
inboundCache.set(model, entry);
|
|
}
|
|
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);
|
|
if (owner && owner !== key) {
|
|
throw new JsonMappingError(
|
|
`Properties "${owner}" and "${key}" both map to the JSON name ${JSON.stringify(external)}. ` +
|
|
`Give one of them a distinct @JsonProperty name.`
|
|
);
|
|
}
|
|
accept.set(external, key);
|
|
};
|
|
|
|
for (const [key, property] of Object.entries(model)) {
|
|
const names = [outboundName(model, key, ctx.naming), ...(property.aliases ?? [])];
|
|
|
|
const access = accessOf(model, key);
|
|
if (access === 'none' || access === 'readonly') {
|
|
for (const name of names) blocked.add(name);
|
|
continue;
|
|
}
|
|
|
|
for (const name of names) claim(name, key);
|
|
|
|
if (property.deserializer || property.polymorphic || property.type) {
|
|
props.set(key, {
|
|
...(property.deserializer ? { deserializer: property.deserializer } : {}),
|
|
...(property.polymorphic ? { polymorphic: property.polymorphic } : {}),
|
|
...(property.type ? { typeFn: property.type } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
const result = { accept, blocked, props };
|
|
entry.byStrategy.set(ctx.namingKey, result);
|
|
return result;
|
|
}
|
|
|
|
|
|
/**
|
|
* The engines below are written synchronously. Anything a user hook makes asynchronous — a
|
|
* serializer, deserializer or validator that returns a Promise — is recorded here instead of
|
|
* being awaited inline, and reconciled once at the end.
|
|
*
|
|
* This buys two things. The `*Sync` entry points can simply refuse to continue if the list is
|
|
* non-empty, without a second copy of the traversal logic to keep in step. And the async entry
|
|
* points stop paying for a microtask per property on the overwhelmingly common path where no
|
|
* hook is actually asynchronous.
|
|
*/
|
|
type Deferred = Promise<unknown>[];
|
|
|
|
function isThenable(value: any): value is Promise<any> {
|
|
return value !== null && typeof value === 'object' && typeof value.then === 'function';
|
|
}
|
|
|
|
/** Settles any deferred work recorded during a traversal. */
|
|
async function settle(deferred: Deferred): Promise<void> {
|
|
while (deferred.length > 0) {
|
|
// A hook may itself queue more work (a serializer returning nested async values).
|
|
const batch = deferred.splice(0, deferred.length);
|
|
await Promise.all(batch);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rejects a synchronous call that turned out to need asynchronous work.
|
|
*/
|
|
function refuseAsync(deferred: Deferred, operation: string, asyncName: string): void {
|
|
if (deferred.length === 0) return;
|
|
// Nothing will await these now; swallow rejections so they do not surface as unhandled.
|
|
for (const promise of deferred) promise.catch(() => undefined);
|
|
deferred.length = 0;
|
|
throw new JsonMappingError(
|
|
`${operation} requires every serializer, deserializer and validator to be synchronous, ` +
|
|
`but one returned a Promise. Use ${asyncName} instead, or make the hook synchronous.`
|
|
);
|
|
}
|
|
|
|
function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number, deferred: Deferred): 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();
|
|
}
|
|
|
|
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(serialize(item, ancestors, ctx, depth + 1, deferred));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const model = modelOfInstance(obj);
|
|
|
|
const result: any = {};
|
|
for (const key of Object.keys(obj)) {
|
|
const property = outboundFor(model, key, ctx);
|
|
if (property.skip) continue;
|
|
|
|
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.
|
|
if (property.serializer && value !== null && value !== undefined) {
|
|
const produced = converterFor(property.serializer).serialize(value);
|
|
if (isThenable(produced)) {
|
|
const slot = property.name;
|
|
// Claim the key now so the deferred write lands in declaration order rather than
|
|
// being appended after every synchronous property.
|
|
result[slot] = undefined;
|
|
deferred.push(produced.then((settled: any) => { result[slot] = settled; }));
|
|
} else {
|
|
result[property.name] = produced;
|
|
}
|
|
} else {
|
|
result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext, depth: number, deferred: Deferred): 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)) {
|
|
return plain.map(item => deserialize(clazz, item, ctx, depth + 1, deferred)) as any;
|
|
}
|
|
|
|
if (typeof plain !== 'object') return plain;
|
|
|
|
const instance = new clazz();
|
|
const inbound = inboundNameMap(modelOf(clazz), ctx);
|
|
|
|
for (const incoming of Object.keys(plain)) {
|
|
if (FORBIDDEN_KEYS.has(incoming)) continue;
|
|
|
|
// A declared property the payload is not allowed to set. Ignoring it is deliberate:
|
|
// rejecting the whole request because a client echoed back a server-owned id is worse
|
|
// than quietly refusing to honour it.
|
|
if (inbound.blocked.has(incoming)) continue;
|
|
|
|
const key = inbound.accept.get(incoming);
|
|
if (key === undefined) {
|
|
// Not a declared property under the active naming strategy.
|
|
if (ctx.unknownKeys === 'strip') continue;
|
|
if (ctx.unknownKeys === 'error') {
|
|
throw new JsonMappingError(
|
|
`Unknown property ${JSON.stringify(incoming)} for ${clazz.name}. ` +
|
|
`Allowed: ${[...inbound.accept.keys()].map(k => JSON.stringify(k)).join(', ') || '(none declared)'}.`
|
|
);
|
|
}
|
|
instance[incoming as keyof T] = plain[incoming];
|
|
continue;
|
|
}
|
|
|
|
const value = plain[incoming];
|
|
|
|
const property = inbound.props.get(key);
|
|
|
|
// Custom Deserializer
|
|
if (property?.deserializer) {
|
|
const produced = converterFor(property.deserializer).deserialize(value);
|
|
if (isThenable(produced)) {
|
|
const slot = key as keyof T;
|
|
// Claim the key now so property order matches the synchronous path.
|
|
instance[slot] = undefined as any;
|
|
deferred.push(produced.then((settled: any) => { instance[slot] = settled; }));
|
|
} else {
|
|
instance[key as keyof T] = produced;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Polymorphic
|
|
const poly = property?.polymorphic;
|
|
if (poly && value !== null && value !== undefined) {
|
|
const { discriminator, subTypes, onUnknown, fallback } = poly;
|
|
|
|
const resolve = (item: any): 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, depth + 1, deferred);
|
|
if (fallback) return deserialize(fallback, item, ctx, depth + 1, deferred);
|
|
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) ? value.map(resolve) as any : resolve(value);
|
|
continue;
|
|
}
|
|
|
|
// Nested Type
|
|
const typeFn = property?.typeFn;
|
|
if (typeFn && value !== null && value !== undefined) {
|
|
const type = typeFn();
|
|
instance[key as keyof T] = deserialize(type, value, ctx, depth + 1, deferred);
|
|
continue;
|
|
}
|
|
|
|
instance[key as keyof T] = value;
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* Everything the validator needs to know about one property, resolved once.
|
|
*/
|
|
interface PropertyPlan {
|
|
key: string;
|
|
constraints: ValidationConstraint[];
|
|
isOptional: boolean;
|
|
isNested: boolean;
|
|
/** True for properties that never leave the process (@JsonWriteOnly / @JsonIgnore). */
|
|
redact: boolean;
|
|
condition?: (object: any) => boolean;
|
|
}
|
|
|
|
interface CachedPlan {
|
|
version: number;
|
|
plan: PropertyPlan[];
|
|
}
|
|
|
|
// Turning a class model into a per-property plan is cheap, but doing it on every call was
|
|
// measurably not: profiling showed roughly half of all validation time re-deriving answers
|
|
// that cannot change. Plans are memoized per model and invalidated by the model version.
|
|
const planCache = new WeakMap<ClassModel, CachedPlan>();
|
|
|
|
/**
|
|
* Collapses rules that are genuinely identical.
|
|
*
|
|
* Inheritance is structural here: a subclass's model starts as a copy of its base's, so
|
|
* re-stating `@IsString()` on an override would otherwise report the same failure twice.
|
|
* Only rules with a fixed message are compared — anything with a computed message, custom
|
|
* validators in particular, is always kept, since two of them can differ while looking alike.
|
|
*/
|
|
function dedupe(constraints: ValidationConstraint[]): ValidationConstraint[] {
|
|
const kept: ValidationConstraint[] = [];
|
|
const seen = new Set<string>();
|
|
for (const constraint of constraints) {
|
|
if (typeof constraint.message === 'string') {
|
|
const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}|${constraint.each ?? false}`;
|
|
if (seen.has(identity)) continue;
|
|
seen.add(identity);
|
|
}
|
|
kept.push(constraint);
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
function validationPlan(model: ClassModel): PropertyPlan[] {
|
|
const cached = planCache.get(model);
|
|
if (cached && cached.version === modelVersion()) {
|
|
return cached.plan;
|
|
}
|
|
|
|
const plan: PropertyPlan[] = [];
|
|
for (const [key, property] of Object.entries(model) as [string, PropertyModel][]) {
|
|
const access = property.access ?? 'readwrite';
|
|
plan.push({
|
|
key,
|
|
constraints: dedupe(property.constraints),
|
|
isOptional: !!property.optional,
|
|
isNested: !!property.nested,
|
|
redact: access === 'writeonly' || access === 'none',
|
|
...(property.condition ? { condition: property.condition } : {}),
|
|
});
|
|
}
|
|
|
|
planCache.set(model, { version: modelVersion(), 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).
|
|
*/
|
|
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;
|
|
}
|
|
|
|
|
|
interface EachOutcome {
|
|
ok: boolean;
|
|
/** Index of the element that failed, or -1. */
|
|
index: number;
|
|
}
|
|
|
|
/** Builds the reported message, applying the `each` decoration only to library defaults. */
|
|
function messageFor(constraint: ValidationConstraint, args: ValidationArguments, failedIndex: number): string {
|
|
let message = typeof constraint.message === 'function' ? constraint.message(args) : constraint.message;
|
|
if (constraint.each && !constraint.hasCustomMessage) {
|
|
message = failedIndex >= 0
|
|
? `each element in ${message} (failed at index ${failedIndex})`
|
|
: `each element in ${message}`;
|
|
}
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Runs an `each: true` constraint over an array, staying synchronous until a validator
|
|
* actually returns a Promise and only then continuing asynchronously.
|
|
*/
|
|
function evaluateEach(constraint: ValidationConstraint, items: any[], args: ValidationArguments): EachOutcome | Promise<EachOutcome> {
|
|
for (let i = 0; i < items.length; i++) {
|
|
args.value = items[i];
|
|
const result = constraint.validate(items[i], args);
|
|
if (isThenable(result)) {
|
|
const base = { object: args.object, property: args.property, constraints: args.constraints };
|
|
const tail = finishEach(constraint, items, i, result, base);
|
|
args.value = items;
|
|
return tail;
|
|
}
|
|
if (!result) {
|
|
args.value = items;
|
|
return { ok: false, index: i };
|
|
}
|
|
}
|
|
args.value = items;
|
|
return { ok: true, index: -1 };
|
|
}
|
|
|
|
async function finishEach(
|
|
constraint: ValidationConstraint,
|
|
items: any[],
|
|
startIndex: number,
|
|
firstResult: Promise<boolean>,
|
|
base: Omit<ValidationArguments, 'value'>
|
|
): Promise<EachOutcome> {
|
|
if (!(await firstResult)) return { ok: false, index: startIndex };
|
|
for (let i = startIndex + 1; i < items.length; i++) {
|
|
if (!(await constraint.validate(items[i], { ...base, value: items[i] }))) {
|
|
return { ok: false, index: i };
|
|
}
|
|
}
|
|
return { ok: true, index: -1 };
|
|
}
|
|
|
|
/**
|
|
* Drops entries that ended up with nothing to report.
|
|
*
|
|
* With an asynchronous validator the verdict is not known while the tree is being built, so
|
|
* candidate entries are created up front and pruned once everything has settled.
|
|
*/
|
|
function pruneErrors(errors: ValidationError[]): ValidationError[] {
|
|
const kept: ValidationError[] = [];
|
|
for (const error of errors) {
|
|
const children = error.children ? pruneErrors(error.children) : undefined;
|
|
if (children && children.length > 0) {
|
|
error.children = children;
|
|
} else {
|
|
delete error.children;
|
|
}
|
|
if (Object.keys(error.constraints).length > 0 || error.children) {
|
|
kept.push(error);
|
|
}
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
function validateInternal(obj: any, ancestors: Set<any>, depth: number, maxDepth: number, deferred: Deferred): 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;
|
|
ancestors.add(obj);
|
|
|
|
try {
|
|
if (Array.isArray(obj)) {
|
|
for (let i = 0; i < obj.length; i++) {
|
|
const childErrors = validateInternal(obj[i], ancestors, depth + 1, maxDepth, deferred);
|
|
if (childErrors.length > 0) {
|
|
errors.push({
|
|
property: `[${i}]`,
|
|
value: obj[i],
|
|
constraints: {},
|
|
children: childErrors
|
|
});
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
for (const property of validationPlan(modelOfInstance(obj))) {
|
|
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,
|
|
// A property that never leaves the process — a @JsonWriteOnly password, say — must
|
|
// not have its value copied into an error object that is about to be logged.
|
|
value: property.redact ? REDACTED : value,
|
|
constraints: {}
|
|
};
|
|
|
|
const validationArgs: ValidationArguments = {
|
|
value: value,
|
|
object: obj,
|
|
property: key,
|
|
constraints: []
|
|
};
|
|
|
|
let awaited = false;
|
|
|
|
for (const constraint of property.constraints) {
|
|
validationArgs.constraints = constraint.constraints || [];
|
|
|
|
const outcome: EachOutcome | Promise<EachOutcome> = constraint.each && Array.isArray(value)
|
|
// 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.
|
|
? evaluateEach(constraint, value, validationArgs)
|
|
: (() => {
|
|
const result = constraint.validate(value, validationArgs);
|
|
return isThenable(result)
|
|
? result.then((ok: boolean) => ({ ok, index: -1 }))
|
|
: { ok: result as boolean, index: -1 };
|
|
})();
|
|
|
|
if (isThenable(outcome)) {
|
|
awaited = true;
|
|
// The shared args object is reused as the loop advances, so snapshot what the
|
|
// message will need before handing control back.
|
|
const snapshot: ValidationArguments = {
|
|
value: value,
|
|
object: obj,
|
|
property: key,
|
|
constraints: constraint.constraints || []
|
|
};
|
|
deferred.push(outcome.then(({ ok, index }: EachOutcome) => {
|
|
if (ok) return;
|
|
if (index >= 0) snapshot.value = value[index];
|
|
recordFailure(propertyErrors.constraints, constraint.name, messageFor(constraint, snapshot, index));
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
if (!outcome.ok) {
|
|
if (outcome.index >= 0) validationArgs.value = value[outcome.index];
|
|
const message = messageFor(constraint, validationArgs, outcome.index);
|
|
validationArgs.value = value;
|
|
recordFailure(propertyErrors.constraints, constraint.name, message);
|
|
}
|
|
}
|
|
|
|
// Recursive validation
|
|
if (property.isNested && value !== null && value !== undefined) {
|
|
const nestedErrors = validateInternal(value, ancestors, depth + 1, maxDepth, deferred);
|
|
if (nestedErrors.length > 0) {
|
|
propertyErrors.children = nestedErrors;
|
|
}
|
|
}
|
|
|
|
// `awaited` entries are kept provisionally: their verdict is not known yet, and
|
|
// pruneErrors() drops the ones that turn out to be clean.
|
|
if (awaited || Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) {
|
|
errors.push(propertyErrors);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
} finally {
|
|
ancestors.delete(obj);
|
|
}
|
|
}
|
|
|
|
function serializeContext(options?: TransformOptions): SerializeContext {
|
|
const resolved = resolveOptions(options);
|
|
return {
|
|
naming: resolveNamingStrategy(resolved.namingStrategy),
|
|
namingKey: resolved.namingStrategy,
|
|
maxDepth: resolved.maxDepth,
|
|
};
|
|
}
|
|
|
|
function deserializeContext(options?: TransformOptions): DeserializeContext {
|
|
const resolved = resolveOptions(options);
|
|
return {
|
|
naming: resolveNamingStrategy(resolved.namingStrategy),
|
|
namingKey: resolved.namingStrategy,
|
|
unknownKeys: resolved.unknownKeys,
|
|
maxDepth: resolved.maxDepth,
|
|
};
|
|
}
|
|
|
|
// --- Public API Functions ---
|
|
|
|
/**
|
|
* Validates a class instance or object against its decorators.
|
|
*
|
|
* @param obj The object to validate
|
|
* @param options Per-call options (currently `maxDepth`)
|
|
* @returns Array of validation errors
|
|
*/
|
|
export async function validate(obj: any, options?: TransformOptions): Promise<ValidationError[]> {
|
|
const deferred: Deferred = [];
|
|
const errors = validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth, deferred);
|
|
if (deferred.length === 0) return errors;
|
|
await settle(deferred);
|
|
return pruneErrors(errors);
|
|
}
|
|
|
|
/**
|
|
* Synchronous {@link validate}.
|
|
*
|
|
* @throws JsonMappingError if any validator returns a Promise.
|
|
*/
|
|
export function validateSync(obj: any, options?: TransformOptions): ValidationError[] {
|
|
const deferred: Deferred = [];
|
|
const errors = validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth, deferred);
|
|
refuseAsync(deferred, 'validateSync()', 'validate()');
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Validates an object and throws {@link JsonValidationError} if it fails.
|
|
*
|
|
* The counterpart to {@link validate} for callers who want an exception rather than an
|
|
* array they have to remember to check.
|
|
*/
|
|
export async function validateOrReject(obj: any, options?: TransformOptions): Promise<void> {
|
|
const errors = await validate(obj, options);
|
|
if (errors.length > 0) {
|
|
throw new JsonValidationError('Validation failed', errors);
|
|
}
|
|
}
|
|
|
|
/** Synchronous {@link validateOrReject}. */
|
|
export function validateOrRejectSync(obj: any, options?: TransformOptions): void {
|
|
const errors = validateSync(obj, options);
|
|
if (errors.length > 0) {
|
|
throw new JsonValidationError('Validation failed', errors);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts a class instance to a plain object.
|
|
*
|
|
* Validates first and throws {@link JsonValidationError} on failure, unless
|
|
* `{ validate: false }` is passed.
|
|
*
|
|
* @param obj The class instance to transform
|
|
* @param options Per-call transform options
|
|
* @returns Plain object
|
|
*/
|
|
export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<any> {
|
|
if (obj === null || obj === undefined) return obj;
|
|
|
|
if (resolveOptions(options).validate) {
|
|
const errors = await validate(obj, options);
|
|
if (errors.length > 0) {
|
|
throw new JsonValidationError('Validation failed during serialization', errors);
|
|
}
|
|
}
|
|
|
|
const deferred: Deferred = [];
|
|
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
|
|
await settle(deferred);
|
|
return plain;
|
|
}
|
|
|
|
/**
|
|
* Synchronous {@link toPlain}.
|
|
*
|
|
* @throws JsonMappingError if any serializer or validator returns a Promise.
|
|
*/
|
|
export function toPlainSync<T>(obj: T, options?: TransformOptions): any {
|
|
if (obj === null || obj === undefined) return obj;
|
|
|
|
if (resolveOptions(options).validate) {
|
|
const errors = validateSync(obj, options);
|
|
if (errors.length > 0) {
|
|
throw new JsonValidationError('Validation failed during serialization', errors);
|
|
}
|
|
}
|
|
|
|
const deferred: Deferred = [];
|
|
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
|
|
refuseAsync(deferred, 'toPlainSync()', 'toPlain()');
|
|
return plain;
|
|
}
|
|
|
|
/**
|
|
* Converts a class instance to a JSON string.
|
|
* @param obj The class instance to transform
|
|
* @param options Per-call transform options
|
|
* @returns JSON string
|
|
*/
|
|
export async function toJson<T>(obj: T, options?: TransformOptions): Promise<string> {
|
|
return JSON.stringify(await toPlain(obj, options));
|
|
}
|
|
|
|
/** Synchronous {@link toJson}. */
|
|
export function toJsonSync<T>(obj: T, options?: TransformOptions): string {
|
|
return JSON.stringify(toPlainSync(obj, options));
|
|
}
|
|
|
|
/**
|
|
* Converts a plain object to a class instance.
|
|
*
|
|
* Validates the result and throws {@link JsonValidationError} on failure, unless
|
|
* `{ validate: false }` is passed.
|
|
*
|
|
* @param clazz The class constructor
|
|
* @param plain The plain object to transform
|
|
* @param options Per-call transform options
|
|
* @returns Class instance
|
|
*/
|
|
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
|
|
const deferred: Deferred = [];
|
|
const instance = deserialize(clazz, plain, deserializeContext(options), 0, deferred);
|
|
await settle(deferred);
|
|
|
|
if (resolveOptions(options).validate) {
|
|
const errors = await validate(instance, options);
|
|
if (errors.length > 0) {
|
|
throw new JsonValidationError('Validation failed during deserialization', errors);
|
|
}
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* Synchronous {@link toInstance}.
|
|
*
|
|
* @throws JsonMappingError if any deserializer or validator returns a Promise.
|
|
*/
|
|
export function toInstanceSync<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): T {
|
|
const deferred: Deferred = [];
|
|
const instance = deserialize(clazz, plain, deserializeContext(options), 0, deferred);
|
|
refuseAsync(deferred, 'toInstanceSync()', 'toInstance()');
|
|
|
|
if (resolveOptions(options).validate) {
|
|
const errors = validateSync(instance, options);
|
|
if (errors.length > 0) {
|
|
throw new JsonValidationError('Validation failed during deserialization', errors);
|
|
}
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
function requireArray<T>(clazz: ClassConstructor<T>, plain: any): void {
|
|
if (!Array.isArray(plain)) {
|
|
throw new JsonMappingError(`Expected an array to map to ${clazz.name}[], received ${typeof plain}.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts an array of plain objects to an array of class instances.
|
|
*
|
|
* `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
|
|
* @param options Per-call transform options
|
|
* @returns Array of class instances
|
|
*/
|
|
export async function toInstanceArray<T>(
|
|
clazz: ClassConstructor<T>,
|
|
plain: any[],
|
|
options?: TransformOptions
|
|
): Promise<T[]> {
|
|
requireArray(clazz, plain);
|
|
return (await toInstance(clazz, plain, options)) as unknown as T[];
|
|
}
|
|
|
|
/** Synchronous {@link toInstanceArray}. */
|
|
export function toInstanceArraySync<T>(
|
|
clazz: ClassConstructor<T>,
|
|
plain: any[],
|
|
options?: TransformOptions
|
|
): T[] {
|
|
requireArray(clazz, plain);
|
|
return toInstanceSync(clazz, plain, options) as unknown as T[];
|
|
}
|
|
|
|
/**
|
|
* Parses a JSON string to a class instance.
|
|
* @param clazz The class constructor
|
|
* @param json JSON string
|
|
* @param options Per-call transform options
|
|
* @returns Class instance
|
|
*/
|
|
export async function fromJson<T>(clazz: ClassConstructor<T>, json: string, options?: TransformOptions): Promise<T> {
|
|
return toInstance(clazz, parseJson(json), options);
|
|
}
|
|
|
|
/** Synchronous {@link fromJson}. */
|
|
export function fromJsonSync<T>(clazz: ClassConstructor<T>, json: string, options?: TransformOptions): T {
|
|
return toInstanceSync(clazz, parseJson(json), options);
|
|
}
|
|
|
|
/**
|
|
* Parses a JSON string containing an array into class instances.
|
|
* @param clazz The class constructor
|
|
* @param json JSON string holding an array
|
|
* @param options Per-call transform options
|
|
* @returns Array of class instances
|
|
*/
|
|
export async function fromJsonArray<T>(
|
|
clazz: ClassConstructor<T>,
|
|
json: string,
|
|
options?: TransformOptions
|
|
): Promise<T[]> {
|
|
return toInstanceArray(clazz, parseJson(json), options);
|
|
}
|
|
|
|
/** Synchronous {@link fromJsonArray}. */
|
|
export function fromJsonArraySync<T>(
|
|
clazz: ClassConstructor<T>,
|
|
json: string,
|
|
options?: TransformOptions
|
|
): T[] {
|
|
return toInstanceArraySync(clazz, parseJson(json), options);
|
|
}
|
|
|
|
function parseJson(json: string): any {
|
|
try {
|
|
return JSON.parse(json);
|
|
} catch (error) {
|
|
throw new JsonMappingError(
|
|
`Input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper for Fetch-based frameworks (Next.js, Hono, etc.)
|
|
* Extracts JSON from a Request and transforms it to a class instance.
|
|
*
|
|
* There is no synchronous counterpart: reading a Request body is inherently asynchronous.
|
|
*
|
|
* @param clazz The class constructor
|
|
* @param request Web Request object
|
|
* @param options Per-call transform options
|
|
* @returns Class instance
|
|
*/
|
|
export async function fromRequest<T>(
|
|
clazz: ClassConstructor<T>,
|
|
request: Request,
|
|
options?: TransformOptions
|
|
): Promise<T> {
|
|
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, options);
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use standalone functions like toPlain, toInstance, etc.
|
|
*/
|
|
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;
|
|
static validateOrReject = validateOrReject;
|
|
}
|