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
372 lines
10 KiB
TypeScript
372 lines
10 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
IsNumber,
|
|
IsObject,
|
|
IsDefined,
|
|
IsNotEmpty,
|
|
Positive,
|
|
Negative,
|
|
IsUrl,
|
|
Max,
|
|
Matches,
|
|
ArrayMinSize,
|
|
ArrayMaxSize,
|
|
IsNotIn,
|
|
Validate,
|
|
defineRule,
|
|
JsonType,
|
|
JsonPolymorphic,
|
|
JsonMapper,
|
|
JsonValidationError,
|
|
ValidationArguments,
|
|
ValidatorConstraintInterface,
|
|
IsString
|
|
} from './index.js';
|
|
|
|
describe('Additional Decorators', () => {
|
|
describe('IsNumber', () => {
|
|
class Test {
|
|
@IsNumber()
|
|
val: any;
|
|
}
|
|
it('should validate numbers', async () => {
|
|
const t = new Test();
|
|
t.val = 123;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = '123';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
t.val = NaN;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('IsObject', () => {
|
|
class Test {
|
|
@IsObject()
|
|
val: any;
|
|
}
|
|
it('should validate objects', async () => {
|
|
const t = new Test();
|
|
t.val = { a: 1 };
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = 123;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
t.val = null;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
t.val = [];
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('IsDefined', () => {
|
|
class Test {
|
|
@IsDefined()
|
|
val: any;
|
|
}
|
|
it('should validate defined values', async () => {
|
|
const t = new Test();
|
|
t.val = 0;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = null;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
t.val = undefined;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('IsNotEmpty', () => {
|
|
class Test {
|
|
@IsNotEmpty()
|
|
val: any;
|
|
}
|
|
it('should validate non-empty values', async () => {
|
|
const t = new Test();
|
|
t.val = 'abc';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = '';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
t.val = null;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('Positive and Negative', () => {
|
|
class Test {
|
|
@Positive()
|
|
pos: number;
|
|
@Negative()
|
|
neg: number;
|
|
}
|
|
it('should validate positive and negative numbers', async () => {
|
|
const t = new Test();
|
|
t.pos = 5;
|
|
t.neg = -5;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.pos = -1;
|
|
t.neg = 1;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(2);
|
|
const errors = await JsonMapper.validate(t);
|
|
expect(errors.find(e => e.property === 'pos')).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Matches', () => {
|
|
class Test {
|
|
@Matches(/^[a-z]+$/)
|
|
val: string;
|
|
@Max(10)
|
|
num: number;
|
|
}
|
|
it('should validate regex matches and Max', async () => {
|
|
const t = new Test();
|
|
t.val = 'abc';
|
|
t.num = 5;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = '123';
|
|
t.num = 15;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('IsUrl invalid', () => {
|
|
class Test {
|
|
@IsUrl()
|
|
url: string;
|
|
}
|
|
it('should fail on invalid URL', async () => {
|
|
const t = new Test();
|
|
t.url = 'not-a-url';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('JsonValidationError and toPlain error', () => {
|
|
it('should have a working toString on JsonValidationError', () => {
|
|
const err = new JsonValidationError('fail', [{ property: 'x', value: 1, constraints: { c: 'm' } }]);
|
|
expect(err.toString()).toContain('fail');
|
|
expect(err.toString()).toContain('x');
|
|
});
|
|
|
|
it('should throw during serialization if invalid', async () => {
|
|
class Test {
|
|
@IsString()
|
|
val: any;
|
|
}
|
|
const t = new Test();
|
|
t.val = 123;
|
|
await expect(JsonMapper.toPlain(t)).rejects.toThrow(JsonValidationError);
|
|
});
|
|
});
|
|
|
|
describe('Array Size', () => {
|
|
class Test {
|
|
@ArrayMinSize(2)
|
|
@ArrayMaxSize(4)
|
|
vals: any[];
|
|
}
|
|
it('should validate array size', async () => {
|
|
const t = new Test();
|
|
t.vals = [1, 2];
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.vals = [1];
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
t.vals = [1, 2, 3, 4, 5];
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('IsNotIn', () => {
|
|
class Test {
|
|
@IsNotIn(['black', 'white'])
|
|
color: string;
|
|
}
|
|
it('should validate value is NOT in list', async () => {
|
|
const t = new Test();
|
|
t.color = 'red';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.color = 'black';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('JsonType', () => {
|
|
class Child {
|
|
@IsString()
|
|
name: string;
|
|
}
|
|
class Parent {
|
|
@JsonType(() => Child)
|
|
child: Child;
|
|
}
|
|
it('should deserialize nested type using JsonType', async () => {
|
|
const plain = { child: { name: 'Junior' } };
|
|
const parent = await JsonMapper.toInstance(Parent, plain);
|
|
expect(parent.child).toBeInstanceOf(Child);
|
|
expect(parent.child.name).toBe('Junior');
|
|
});
|
|
});
|
|
|
|
describe('Custom Validate', () => {
|
|
it('should use functional validator', async () => {
|
|
class Test {
|
|
@Validate((v) => v === 'secret')
|
|
val: string;
|
|
}
|
|
const t = new Test();
|
|
t.val = 'secret';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = 'wrong';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
|
|
it('should use class-based validator', async () => {
|
|
class CustomValidator implements ValidatorConstraintInterface {
|
|
validate(value: any) {
|
|
return value === 'correct';
|
|
}
|
|
defaultMessage(args: ValidationArguments) {
|
|
return `${args.property} must be correct`;
|
|
}
|
|
}
|
|
class Test {
|
|
@Validate(CustomValidator)
|
|
val: string;
|
|
}
|
|
const t = new Test();
|
|
t.val = 'correct';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = 'wrong';
|
|
const errors = await JsonMapper.validate(t);
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0]!.constraints['CustomValidator']).toBe('val must be correct');
|
|
});
|
|
});
|
|
|
|
describe('registerDecorator', () => {
|
|
it('should register a custom decorator with functional validator', async () => {
|
|
class Test {
|
|
val: number = 0;
|
|
}
|
|
defineRule(Test, 'val', {
|
|
name: 'isEven',
|
|
validate: (value: any) => typeof value === 'number' && value % 2 === 0,
|
|
message: 'val must be even',
|
|
});
|
|
|
|
const t = new Test();
|
|
t.val = 2;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = 3;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
|
|
it('should register a custom decorator with class validator', async () => {
|
|
class MyValidator implements ValidatorConstraintInterface {
|
|
validate(v: any) { return v === 'ok'; }
|
|
}
|
|
class Test {
|
|
val: string = '';
|
|
}
|
|
const validator = new MyValidator();
|
|
defineRule(Test, 'val', {
|
|
name: 'isOk',
|
|
validate: (v: any) => validator.validate(v),
|
|
message: 'val must be ok',
|
|
});
|
|
const t = new Test();
|
|
t.val = 'ok';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = 'not ok';
|
|
expect(await JsonMapper.validate(t)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('Validate with constraints and options', () => {
|
|
it('should handle constraints and options', async () => {
|
|
class Test {
|
|
@Validate((v, a) => v === a.constraints[0], [10], { message: 'must be ten' })
|
|
val: number;
|
|
}
|
|
const t = new Test();
|
|
t.val = 10;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = 5;
|
|
const errors = await JsonMapper.validate(t);
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0]!.constraints['custom']).toBe('must be ten');
|
|
});
|
|
|
|
it('should handle options as second argument', async () => {
|
|
class Test {
|
|
@Validate((v) => v === 1, { message: 'must be one' })
|
|
val: number;
|
|
}
|
|
const t = new Test();
|
|
t.val = 1;
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.val = 2;
|
|
const errors = await JsonMapper.validate(t);
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0]!.constraints['custom']).toBe('must be one');
|
|
});
|
|
});
|
|
|
|
describe('JsonMapper extra cases', () => {
|
|
it('should serialize Date with default toISOString', async () => {
|
|
class Test {
|
|
date: Date;
|
|
}
|
|
const t = new Test();
|
|
t.date = new Date("2026-01-01T00:00:00Z");
|
|
const plain = await JsonMapper.toPlain(t);
|
|
expect(plain.date).toBe(t.date.toISOString());
|
|
});
|
|
|
|
it('should deserialize top-level array', async () => {
|
|
class Item {
|
|
@IsString()
|
|
name: string;
|
|
}
|
|
const json = '[{"name": "a"}, {"name": "b"}]';
|
|
const items = (await JsonMapper.fromJson(Item, json)) as unknown as Item[];
|
|
expect(Array.isArray(items)).toBe(true);
|
|
expect(items[0]).toBeInstanceOf(Item);
|
|
expect(items[0]!.name).toBe('a');
|
|
});
|
|
|
|
it('should handle single polymorphic object', async () => {
|
|
abstract class Animal {
|
|
@IsString() type: string;
|
|
}
|
|
class Dog extends Animal {
|
|
override type = 'dog';
|
|
@IsString() breed: string;
|
|
}
|
|
class Test {
|
|
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
|
|
pet: Animal;
|
|
}
|
|
const plain = { pet: { type: 'dog', breed: 'Labrador' } };
|
|
const t = await JsonMapper.toInstance(Test, plain);
|
|
expect(t.pet).toBeInstanceOf(Dog);
|
|
expect((t.pet as Dog).breed).toBe('Labrador');
|
|
});
|
|
});
|
|
|
|
describe('ValidationOptions each', () => {
|
|
class Test {
|
|
@IsString({ each: true })
|
|
tags: string[];
|
|
}
|
|
it('should validate each element in array', async () => {
|
|
const t = new Test();
|
|
t.tags = ['a', 'b'];
|
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
|
t.tags = ['a', 1 as any];
|
|
const errors = await JsonMapper.validate(t);
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0]!.constraints['isString']).toContain('each element');
|
|
});
|
|
});
|
|
});
|