🔊 feat!: make the three silent failures loud

Every change here answers one question: where does cereale currently fail
without saying so?

**Vite 8 / Vitest 4 drop decorators silently.** Both transform with oxc,
which does not implement the standard decorator transform and does not
report that. `vitest` prints "0 test" beside a bare SyntaxError, and
`vite build` reports success while emitting a bundle that throws on first
import. Ship the plugin that fixes it as `cereale/vite`, transforming with
esbuild and falling back to tsc — cereale depends on neither. The library's
own suite now runs through it, so it is exercised by every test.

**Legacy decorators died opaquely.** With `experimentalDecorators: true`,
still the default in most existing TypeScript projects, decorators are
invoked as (prototype, "name") and cereale raised "TypeError: Cannot convert
undefined or null to object". All decorators now resolve metadata through
one checkpoint that names the tsconfig setting instead, and reject
application to a method, getter or accessor field.

**Values JSON cannot carry were emptied.** A populated Map serialized to
{}, a Uint8Array to index-keyed noise, a bigint straight through so the
caller's own JSON.stringify threw somewhere unrelated. All now raise
JsonMappingError naming the property path and both ways out. Covers what a
@JsonSerialize serializer returns, sync or async. Circular-reference and
depth errors name the path too.

Also fixed: defineRule on a subclass with no decorators of its own wrote
the rule into its base class, because the base's metadata object is
inherited through the static prototype chain and `??=` found it non-nullish.

The README's toolchain table (tsc, esbuild, swc ✅, oxc ❌) is now executed
by a test rather than asserted, and the positioning leads with
class-validator + class-transformer, the stack cereale actually replaces,
rather than Zod, which it deliberately is not.

193 -> 249 tests.

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-05 09:11:32 +00:00
parent d56c47d55c
commit 0938300477
14 changed files with 1237 additions and 77 deletions
+131 -12
View File
@@ -242,14 +242,113 @@ function refuseAsync(deferred: Deferred, operation: string, asyncName: string):
);
}
function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number, deferred: Deferred): any {
if (obj === null || obj === undefined || typeof obj !== 'object') {
/**
* Values that carry their data in internal slots rather than in own enumerable properties.
*
* Walking one of these with `Object.keys` yields `{}` — a populated `Map` becomes an empty
* object and nothing anywhere says so. Keyed by `Symbol.toStringTag`, which every one of them
* defines on its prototype, so the lookup costs a single property read and still recognises
* instances that came from another realm.
*/
const UNREPRESENTABLE: Record<string, string> = {
Map: 'a Map',
Set: 'a Set',
WeakMap: 'a WeakMap',
WeakSet: 'a WeakSet',
WeakRef: 'a WeakRef',
Promise: 'a Promise',
ArrayBuffer: 'an ArrayBuffer',
SharedArrayBuffer: 'a SharedArrayBuffer',
DataView: 'a DataView',
Generator: 'a generator',
AsyncGenerator: 'an async generator',
};
/**
* Describes why a value cannot be represented in JSON, or returns null if it can.
*
* The alternative to raising this is what the engine used to do: emit `{}` for a `Map`,
* index-keyed noise for a `Uint8Array`, and a bigint that makes the caller's own
* `JSON.stringify` throw somewhere else entirely. This library's position is that silent
* success is the worst failure mode a mapping layer can have, and that has to include its own.
*/
function unrepresentableObject(value: object): string | null {
const tag = (value as Record<symbol, unknown>)[Symbol.toStringTag];
if (typeof tag === 'string') {
const known = UNREPRESENTABLE[tag];
if (known !== undefined) return known;
// Typed arrays are tagged with their own name and would serialize to `{"0":…,"1":…}`.
if (ArrayBuffer.isView(value)) return `a ${tag}`;
}
// RegExp and Error get their `Object.prototype.toString` tag from a spec special case
// rather than from `Symbol.toStringTag`, so neither is caught above.
if (value instanceof RegExp) return 'a RegExp';
if (value instanceof Error) return 'an Error, whose message and stack are not enumerable';
return null;
}
const BIGINT_REASON = 'a bigint, which JSON has no representation for';
/** The same question for a value of any type. `serialize` inlines the primitive half. */
function unrepresentable(value: unknown): string | null {
switch (typeof value) {
case 'bigint': return BIGINT_REASON;
case 'symbol': return 'a symbol';
case 'function': return 'a function';
case 'object': return value === null ? null : unrepresentableObject(value);
default: return null;
}
}
/**
* The trail of keys walked to reach a value: property names as strings, array positions as
* numbers. Numbers are kept unformatted so that walking an array costs no string building.
*/
type Path = (string | number)[];
/** Renders a {@link Path} for error messages. */
function describePath(path: readonly (string | number)[]): string {
if (path.length === 0) return 'the value passed in';
let out = '';
for (const segment of path) {
if (typeof segment === 'number') out += `[${segment}]`;
else out += out === '' ? segment : `.${segment}`;
}
return out;
}
function refuseUnrepresentable(why: string, path: readonly (string | number)[]): never {
throw new JsonMappingError(
`${describePath(path)} is ${why}, which cannot be serialized to JSON. ` +
'Give the property a @JsonSerialize() serializer that converts it, or drop it from the ' +
'output with @JsonIgnore().'
);
}
/**
* @param path The keys walked to reach `obj`, kept as a stack so that errors can name the
* offending property. Pushed and popped rather than concatenated, so the bookkeeping costs
* no string building on the way down.
*/
function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number, deferred: Deferred, path: Path): any {
if (obj === null || obj === undefined) return obj;
// Primitives dominate the walk, so their check is inline: one `typeof` and, for the three
// types JSON cannot carry, a throw. Everything else defers to `unrepresentableObject`,
// which is only reached once per object and skipped entirely for arrays and dates.
const type = typeof obj;
if (type !== 'object') {
if (type === 'bigint') refuseUnrepresentable(BIGINT_REASON, path);
if (type === 'symbol') refuseUnrepresentable('a symbol', path);
if (type === 'function') refuseUnrepresentable('a function', path);
return obj;
}
if (depth > ctx.maxDepth) {
throw new JsonMappingError(
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing. ` +
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing at ${describePath(path)}. ` +
`Raise it with the maxDepth option if this structure is legitimate.`
);
}
@@ -258,19 +357,28 @@ function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth:
return obj.toISOString();
}
const isArray = Array.isArray(obj);
if (!isArray) {
const why = unrepresentableObject(obj);
if (why !== null) refuseUnrepresentable(why, path);
}
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.'
`Circular reference detected during serialization at ${describePath(path)}. 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)) {
if (isArray) {
const out: any[] = [];
for (const item of obj) {
out.push(serialize(item, ancestors, ctx, depth + 1, deferred));
for (let index = 0; index < obj.length; index++) {
path.push(index);
out.push(serialize(obj[index], ancestors, ctx, depth + 1, deferred, path));
path.pop();
}
return out;
}
@@ -283,6 +391,7 @@ function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth:
if (property.skip) continue;
const value = obj[key];
path.push(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.
@@ -292,14 +401,24 @@ function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth:
const slot = property.name;
// Claim the key now so the deferred write lands in declaration order rather than
// being appended after every synchronous property.
const where = [...path];
result[slot] = undefined;
deferred.push(produced.then((settled: any) => { result[slot] = settled; }));
deferred.push(produced.then((settled: any) => {
const bad = unrepresentable(settled);
if (bad !== null) refuseUnrepresentable(bad, where);
result[slot] = settled;
}));
} else {
// A serializer that hands back a Map is the same silent `{}` by another route.
const bad = unrepresentable(produced);
if (bad !== null) refuseUnrepresentable(bad, path);
result[property.name] = produced;
}
} else {
result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred);
result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred, path);
}
path.pop();
}
return result;
@@ -795,7 +914,7 @@ export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<an
}
const deferred: Deferred = [];
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred, []);
await settle(deferred);
return plain;
}
@@ -816,7 +935,7 @@ export function toPlainSync<T>(obj: T, options?: TransformOptions): any {
}
const deferred: Deferred = [];
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred, []);
refuseAsync(deferred, 'toPlainSync()', 'toPlain()');
return plain;
}