✨ 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
This commit is contained in:
Claude
2026-08-04 20:35:26 +00:00
parent 50c08aa556
commit 297d3bfe77
20 changed files with 1265 additions and 1429 deletions
+11 -12
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
import {
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
JsonSerializer, JsonDeserializer, JsonMappingError,
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
defineRule, validate, toInstance, toPlain, configure, resetConfig,
} from './index.js';
afterEach(() => resetConfig());
@@ -20,11 +20,10 @@ describe('plan caching', () => {
expect(await validate(before)).toEqual([]);
// Register a rule after the plan has already been built and cached.
registerDecorator({
defineRule(Late, 'value', {
name: 'isEven',
target: Late,
propertyName: 'value',
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
validate: (v: any) => typeof v === 'number' && v % 2 === 0,
message: 'value must be even',
});
const after = new Late();
@@ -148,11 +147,11 @@ describe('each: true error reporting', () => {
it('names the index of the element that failed', async () => {
class Basket {
@IsIn(['a', 'b'], { each: true })
tags: string[];
tags!: ('a' | 'b')[];
}
const basket = new Basket();
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
basket.tags = ['a', 'b', 'a', 'nope' as 'a', 'b'];
const errors = await validate(basket);
expect(errors).toHaveLength(1);
@@ -162,10 +161,10 @@ describe('each: true error reporting', () => {
it('leaves a caller-supplied message untouched', async () => {
class Basket {
@IsIn(['a'], { each: true, message: 'bad tag' })
tags: string[];
tags!: 'a'[];
}
const basket = new Basket();
basket.tags = ['a', 'zzz'];
basket.tags = ['a', 'zzz' as 'a'];
const errors = await validate(basket);
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
@@ -174,10 +173,10 @@ describe('each: true error reporting', () => {
it('gives the failing element to a message function, not the whole array', async () => {
class Basket {
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
tags: string[];
tags!: 'a'[];
}
const basket = new Basket();
basket.tags = ['a', 'zzz'];
basket.tags = ['a', 'zzz' as 'a'];
const errors = await validate(basket);
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
@@ -186,7 +185,7 @@ describe('each: true error reporting', () => {
it('reports nothing when every element passes', async () => {
class Basket {
@IsIn(['a', 'b'], { each: true })
tags: string[];
tags!: ('a' | 'b')[];
}
const basket = new Basket();
basket.tags = ['a', 'b'];