Files
cereale/src/sync.test.ts
T
Claude 297d3bfe77 ✨ feat!: v2 — strongly typed decorators on the TC39 standard
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
2026-08-04 20:35:26 +00:00

401 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
IsString, IsInt, Min, MinLength, IsIn, ValidateNested, JsonType, JsonProperty,
JsonSerialize, JsonDeserialize, JsonSerializer, JsonDeserializer,
JsonIgnore, JsonWriteOnly, Validate, JsonMappingError, JsonValidationError, REDACTED,
validate, validateSync, validateOrReject, validateOrRejectSync,
toPlain, toPlainSync, toJson, toJsonSync,
toInstance, toInstanceSync, toInstanceArray, toInstanceArraySync,
fromJsonSync, fromJsonArraySync,
flattenErrors,
} from './index.js';
class Upper implements JsonSerializer<string, string> {
serialize(value: string): string { return value.toUpperCase(); }
}
class Lower implements JsonDeserializer<string, string> {
deserialize(value: string): string { return value.toLowerCase(); }
}
class User {
@JsonProperty('display_name')
@IsString()
@MinLength(2)
displayName: string;
@IsInt()
@Min(0)
age: number;
}
describe('synchronous API', () => {
it('toInstanceSync / fromJsonSync map and validate without a Promise', () => {
const user = toInstanceSync(User, { display_name: 'Ada', age: 36 });
expect(user).toBeInstanceOf(User);
expect(user.displayName).toBe('Ada');
const parsed = fromJsonSync(User, '{"display_name":"Ada","age":36}');
expect(parsed.displayName).toBe('Ada');
});
it('toPlainSync / toJsonSync round-trip', () => {
const user = new User();
user.displayName = 'Ada';
user.age = 36;
expect(toPlainSync(user)).toEqual({ display_name: 'Ada', age: 36 });
expect(toJsonSync(user)).toBe('{"display_name":"Ada","age":36}');
});
it('validateSync returns the same errors as validate', async () => {
const user = new User();
user.displayName = 'A';
user.age = -1;
const sync = validateSync(user);
const async = await validate(user);
expect(flattenErrors(sync)).toEqual(flattenErrors(async));
expect(Object.keys(flattenErrors(sync))).toEqual(['displayName', 'age']);
});
it('throws JsonValidationError on invalid input, like the async form', () => {
expect(() => toInstanceSync(User, { display_name: 'A', age: 5 })).toThrow(JsonValidationError);
expect(() => validateOrRejectSync(Object.assign(new User(), { displayName: 'A', age: 1 })))
.toThrow(JsonValidationError);
});
it('honours options', () => {
const lenient = toInstanceSync(User, { display_name: 'A', age: -1 }, { validate: false });
expect(lenient.displayName).toBe('A');
expect(() => toInstanceSync(User, { display_name: 'Ada', age: 1, stray: 1 }, { unknownKeys: 'error' }))
.toThrow(/Unknown property "stray"/);
});
it('array entry points work synchronously', () => {
class Item {
@IsString()
name: string;
}
expect(toInstanceArraySync(Item, [{ name: 'a' }])[0]!.name).toBe('a');
expect(fromJsonArraySync(Item, '[{"name":"b"}]')[0]!.name).toBe('b');
expect(() => toInstanceArraySync(Item, {} as any)).toThrow(JsonMappingError);
});
it('runs synchronous custom serializers and deserializers', () => {
class Doc {
@JsonSerialize(Upper)
@JsonDeserialize(Lower)
code: string;
}
const doc = toInstanceSync(Doc, { code: 'ABC' }, { validate: false });
expect(doc.code).toBe('abc');
expect(toPlainSync(doc)).toEqual({ code: 'ABC' });
});
it('handles nesting, cycles and depth the same way', () => {
class Child { @IsString() name: string; }
class Parent {
@ValidateNested()
@JsonType(() => Child)
child: Child;
}
const parent = toInstanceSync(Parent, { child: { name: 'x' } });
expect(parent.child).toBeInstanceOf(Child);
const cyclic: any = new Parent();
cyclic.child = cyclic;
expect(() => toPlainSync(cyclic, { validate: false })).toThrow(/Circular reference/);
});
});
describe('synchronous API refuses asynchronous hooks', () => {
class SlowSerializer implements JsonSerializer<string, string> {
async serialize(value: string): Promise<string> { return value.toUpperCase(); }
}
class SlowDeserializer implements JsonDeserializer<string, string> {
async deserialize(value: string): Promise<string> { return value.toLowerCase(); }
}
it('reports a clear error for an async serializer and names the async alternative', () => {
class Doc {
@JsonSerialize(SlowSerializer)
code: string;
}
const doc = new Doc();
doc.code = 'abc';
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
expect(() => toPlainSync(doc, { validate: false })).toThrow(/toPlainSync\(\) requires every/);
expect(() => toPlainSync(doc, { validate: false })).toThrow(/Use toPlain\(\) instead/);
});
it('reports a clear error for an async deserializer', () => {
class Doc {
@JsonDeserialize(SlowDeserializer)
code: string;
}
expect(() => toInstanceSync(Doc, { code: 'ABC' }, { validate: false }))
.toThrow(/toInstanceSync\(\) requires every/);
});
it('reports a clear error for an async validator', () => {
class Doc {
@Validate(async (v: any) => v === 'ok')
code: string;
}
const doc = new Doc();
doc.code = 'ok';
expect(() => validateSync(doc)).toThrow(/validateSync\(\) requires every/);
});
it('does not leave an unhandled rejection behind when it refuses', async () => {
class Exploding implements JsonSerializer<string, string> {
serialize(): Promise<string> { return Promise.reject(new Error('boom')); }
}
class Doc {
@JsonSerialize(Exploding)
code: string;
}
const doc = new Doc();
doc.code = 'x';
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
await new Promise(resolve => setTimeout(resolve, 20));
} finally {
process.off('unhandledRejection', onUnhandled);
}
expect(unhandled).toEqual([]);
});
});
describe('the async API still supports asynchronous hooks', () => {
it('awaits an async serializer', async () => {
class Slow implements JsonSerializer<string, string> {
async serialize(value: string): Promise<string> {
await new Promise(resolve => setTimeout(resolve, 1));
return value.toUpperCase();
}
}
class Doc {
@JsonSerialize(Slow)
code: string;
@IsString()
other: string;
}
const doc = new Doc();
doc.code = 'abc';
doc.other = 'kept';
await expect(toPlain(doc)).resolves.toEqual({ code: 'ABC', other: 'kept' });
await expect(toJson(doc)).resolves.toBe('{"code":"ABC","other":"kept"}');
});
it('awaits an async deserializer, including inside a nested type', async () => {
class Slow implements JsonDeserializer<string, Date> {
async deserialize(value: string): Promise<Date> {
await new Promise(resolve => setTimeout(resolve, 1));
return new Date(value);
}
}
class Child {
@JsonDeserialize(Slow)
at: Date;
}
class Parent {
@JsonType(() => Child)
child: Child;
}
const parent = await toInstance(Parent, { child: { at: '2026-01-01T00:00:00Z' } }, { validate: false });
expect(parent.child.at).toBeInstanceOf(Date);
expect(parent.child.at.getUTCFullYear()).toBe(2026);
});
it('awaits an async deserializer inside an array', async () => {
class Slow implements JsonDeserializer<string, string> {
async deserialize(value: string): Promise<string> { return value.toUpperCase(); }
}
class Row {
@JsonDeserialize(Slow)
code: string;
}
const rows = await toInstanceArray(Row, [{ code: 'a' }, { code: 'b' }], { validate: false });
expect(rows.map(r => r.code)).toEqual(['A', 'B']);
});
it('awaits an async validator and reports its failure', async () => {
class Doc {
@Validate(async (v: any) => {
await new Promise(resolve => setTimeout(resolve, 1));
return v === 'ok';
}, { message: 'must be ok' })
code: string;
}
const doc = new Doc();
doc.code = 'ok';
await expect(validate(doc)).resolves.toEqual([]);
doc.code = 'wrong';
const errors = await validate(doc);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints['custom']).toBe('must be ok');
});
it('awaits an async validator under each: true and keeps the index', async () => {
class Doc {
@Validate(async (v: any) => v === 'ok', { each: true })
codes: string[];
}
const doc = new Doc();
doc.codes = ['ok', 'ok'];
await expect(validate(doc)).resolves.toEqual([]);
doc.codes = ['ok', 'ok', 'bad'];
const errors = await validate(doc);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints['custom']).toContain('failed at index 2');
});
it('mixes sync and async validators on one object without losing either failure', async () => {
class Doc {
@IsString()
name: any;
@Validate(async (v: any) => v > 0, { message: 'must be positive' })
amount: number;
}
const doc = new Doc();
doc.name = 123;
doc.amount = -5;
const flat = flattenErrors(await validate(doc));
expect(flat['name']).toEqual(['name must be a string']);
expect(flat['amount']).toEqual(['must be positive']);
});
it('prunes provisional entries for async validators that pass', async () => {
class Doc {
@Validate(async () => true)
a: string;
@Validate(async () => true)
b: string;
}
await expect(validate(new Doc())).resolves.toEqual([]);
});
it('validateOrReject still rejects on an async failure', async () => {
class Doc {
@Validate(async () => false)
code: string;
}
await expect(validateOrReject(new Doc())).rejects.toThrow(JsonValidationError);
});
});
describe('write-only redaction in validation errors', () => {
it('redacts a @JsonWriteOnly value but keeps the failure message', async () => {
class Credentials {
@IsString()
email: string;
@JsonWriteOnly()
@IsString()
@MinLength(12)
password: string;
}
const creds = new Credentials();
creds.email = 'ada@example.com';
creds.password = 'hunter2';
const errors = await validate(creds);
const failure = errors.find(e => e.property === 'password')!;
expect(failure.value).toBe(REDACTED);
expect(failure.constraints['minLength']).toContain('12');
expect(JSON.stringify(errors)).not.toContain('hunter2');
});
it('redacts @JsonIgnore values too', async () => {
class Record {
@JsonIgnore()
@IsString()
internalSecret: any;
}
const record = new Record();
record.internalSecret = 999;
const errors = await validate(record);
expect(errors[0]!.value).toBe(REDACTED);
expect(JSON.stringify(errors)).not.toContain('999');
});
it('leaves ordinary property values in place', async () => {
class Doc {
@IsString()
name: any;
}
const doc = new Doc();
doc.name = 42;
const errors = await validate(doc);
expect(errors[0]!.value).toBe(42);
});
it('keeps the secret out of a thrown JsonValidationError', async () => {
class SignUp {
@JsonWriteOnly()
@IsString()
@MinLength(12)
password: string;
}
await expect(toInstance(SignUp, { password: 'short' })).rejects.toThrow(JsonValidationError);
try {
await toInstance(SignUp, { password: 'short' });
} catch (error) {
expect(String((error as JsonValidationError).toString())).not.toContain('short');
}
});
it('redacts in the synchronous path as well', () => {
class Credentials {
@JsonWriteOnly()
@IsString()
@MinLength(12)
password: string;
}
const creds = new Credentials();
creds.password = 'hunter2';
expect(validateSync(creds)[0]!.value).toBe(REDACTED);
});
it('does not redact a value that merely sits next to a secret', async () => {
class Form {
@IsIn(['a', 'b'])
choice!: 'a' | 'b';
@JsonWriteOnly()
@IsString()
token: string;
}
const form = new Form();
form.choice = 'zzz' as 'a';
form.token = 'secret-token';
const errors = await validate(form);
expect(errors.find(e => e.property === 'choice')!.value).toBe('zzz');
expect(JSON.stringify(errors)).not.toContain('secret-token');
});
});