✨ 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:
+12
-19
@@ -25,8 +25,7 @@ import {
|
||||
Validate,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions
|
||||
Matches,
|
||||
} from './index.js';
|
||||
|
||||
// --- Custom Validators ---
|
||||
@@ -42,17 +41,8 @@ class IsLongerThan implements ValidatorConstraintInterface {
|
||||
}
|
||||
}
|
||||
|
||||
function IsSlug(options?: ValidationOptions) {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isSlug',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
...(options ? { options } : {}),
|
||||
validator: (value: any) => typeof value === 'string' && /^[a-z0-9-]+$/.test(value)
|
||||
});
|
||||
};
|
||||
}
|
||||
/** A custom rule is just a decorator that composes an existing one. */
|
||||
const IsSlug = () => Matches(/^[a-z0-9-]+$/, { message: 'name must be a lowercase slug' });
|
||||
|
||||
// --- Custom Serializers ---
|
||||
|
||||
@@ -76,12 +66,14 @@ enum Format {
|
||||
}
|
||||
|
||||
abstract class Media {
|
||||
// Standard decorators cannot be applied to an `abstract` member, so the discriminator is a
|
||||
// concrete field the subclasses override.
|
||||
@IsString()
|
||||
abstract type: string;
|
||||
type: string = '';
|
||||
|
||||
// Declared once here. Subclasses inherit the rule without restating it.
|
||||
@IsString()
|
||||
title: string;
|
||||
title: string = '';
|
||||
}
|
||||
|
||||
class Book extends Media {
|
||||
@@ -111,7 +103,7 @@ class Movie extends Media {
|
||||
duration: number;
|
||||
|
||||
// Only checked for films that claim to be part of a series.
|
||||
@ValidateIf((movie: Movie) => movie.duration > 200)
|
||||
@ValidateIf<Movie>(movie => movie.duration > 200)
|
||||
@IsString()
|
||||
intermissionNote?: string;
|
||||
}
|
||||
@@ -122,7 +114,7 @@ class Library {
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsSlug({ message: 'name must be a lowercase slug' })
|
||||
@IsSlug()
|
||||
name: string;
|
||||
|
||||
@JsonProperty('curator_email')
|
||||
@@ -136,11 +128,12 @@ class Library {
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@JsonPolymorphic('type', [
|
||||
// Naming the base type has the subtype list checked against it.
|
||||
@JsonPolymorphic<Media>('type', [
|
||||
{ value: Book, name: 'book' },
|
||||
{ value: Movie, name: 'movie' }
|
||||
])
|
||||
items: Media[];
|
||||
items: Media[] = [];
|
||||
}
|
||||
|
||||
// --- Execution ---
|
||||
|
||||
Reference in New Issue
Block a user