✨ 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:
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* The headline guarantee of v2 is that a rule cannot be attached to a field it does not fit.
|
||||
* That is a *compile-time* claim, so asserting it needs the compiler: each case below is
|
||||
* type-checked in isolation and must fail.
|
||||
*
|
||||
* These run the real `tsc`, so they are slower than the rest of the suite — but a guarantee
|
||||
* nobody checks is a guarantee that quietly stops holding.
|
||||
*/
|
||||
const TSC = resolve('node_modules/.bin/tsc');
|
||||
const SRC = resolve('src/index.js').replace(/\.js$/, '');
|
||||
|
||||
function typeCheck(body: string): { ok: boolean; output: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cereale-types-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext',
|
||||
// DOM supplies URL/Request, which the library's own signatures reference. A real
|
||||
// consumer has these from either DOM or @types/node.
|
||||
lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true,
|
||||
strictPropertyInitialization: false, noEmit: true, skipLibCheck: true,
|
||||
},
|
||||
include: ['case.ts'],
|
||||
}));
|
||||
writeFileSync(join(dir, 'case.ts'), `import {\n IsString, IsInt, Min, MinLength, IsArray, ArrayMinSize, ArrayUnique,\n IsDate, MinDate, IsBoolean, IsIn, IsEnum, JsonType, JsonSerialize,\n JsonDeserialize, JsonSerializer, JsonDeserializer,\n} from ${JSON.stringify(SRC + '.js')};\n\n${body}\n`);
|
||||
try {
|
||||
execFileSync(process.execPath, [TSC, '-p', dir], { stdio: 'pipe' });
|
||||
return { ok: true, output: '' };
|
||||
} catch (error: any) {
|
||||
return { ok: false, output: String(error.stdout ?? '') + String(error.stderr ?? '') };
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const compiles = (body: string) => {
|
||||
const result = typeCheck(body);
|
||||
if (!result.ok) throw new Error(`expected this to compile but it did not:\n${result.output}`);
|
||||
};
|
||||
|
||||
const rejects = (body: string) => {
|
||||
const result = typeCheck(body);
|
||||
expect(result.ok, 'expected a compile error, but it compiled').toBe(false);
|
||||
return result.output;
|
||||
};
|
||||
|
||||
describe('rules are checked against the field type', () => {
|
||||
it('accepts rules that match the field', () => {
|
||||
compiles(`
|
||||
class Ok {
|
||||
@IsString() @MinLength(2) name!: string;
|
||||
@IsInt() @Min(0) age!: number;
|
||||
@IsBoolean() active!: boolean;
|
||||
@IsDate() @MinDate(new Date(0)) when!: Date;
|
||||
@IsArray() @ArrayMinSize(1) tags!: string[];
|
||||
@IsString() nickname?: string;
|
||||
@IsString() maybe!: string | null;
|
||||
}
|
||||
void Ok;
|
||||
`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a string rule on a number field', () => {
|
||||
expect(rejects(`class Bad { @IsString() age!: number } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a number rule on a string field', () => {
|
||||
expect(rejects(`class Bad { @Min(0) label!: string } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an array rule on a non-array field', () => {
|
||||
expect(rejects(`class Bad { @ArrayMinSize(1) count!: number } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a date rule on a string field', () => {
|
||||
expect(rejects(`class Bad { @MinDate(new Date(0)) when!: string } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('each: true moves the rule onto the elements', () => {
|
||||
it('accepts a matching array field', () => {
|
||||
compiles(`class Ok { @IsString({ each: true }) tags!: string[] } void Ok;`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects each:true on a scalar field', () => {
|
||||
expect(rejects(`class Bad { @IsString({ each: true }) tag!: string } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a bare rule on an array field', () => {
|
||||
expect(rejects(`class Bad { @IsString() tags!: string[] } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an element-type mismatch', () => {
|
||||
expect(rejects(`class Bad { @IsString({ each: true }) nums!: number[] } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('nested types and converters are checked', () => {
|
||||
const shapes = `
|
||||
class Address { street!: string }
|
||||
class Money { amount!: number }
|
||||
`;
|
||||
|
||||
it('accepts the matching class', () => {
|
||||
compiles(`${shapes}
|
||||
class Ok {
|
||||
@JsonType(() => Address) ship!: Address;
|
||||
@JsonType(() => Address) history!: Address[];
|
||||
}
|
||||
void Ok;`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an unrelated class', () => {
|
||||
expect(rejects(`${shapes}
|
||||
class Bad { @JsonType(() => Money) ship!: Address }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a serializer whose input does not match the field', () => {
|
||||
expect(rejects(`
|
||||
class DateToString implements JsonSerializer<Date, string> {
|
||||
serialize(v: Date) { return v.toISOString(); }
|
||||
}
|
||||
class Bad { @JsonSerialize(DateToString) name!: string }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a deserializer whose output does not match the field', () => {
|
||||
expect(rejects(`
|
||||
class StringToDate implements JsonDeserializer<string, Date> {
|
||||
deserialize(v: string) { return new Date(v); }
|
||||
}
|
||||
class Bad { @JsonDeserialize(StringToDate) name!: string }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('membership rules narrow the field', () => {
|
||||
it('accepts a field typed as the allowed union', () => {
|
||||
compiles(`class Ok { @IsIn(['a', 'b']) choice!: 'a' | 'b' } void Ok;`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a field that cannot hold the allowed values', () => {
|
||||
expect(rejects(`class Bad { @IsIn(['a', 'b']) choice!: number } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an enum rule on a mismatched field', () => {
|
||||
expect(rejects(`
|
||||
enum Role { Admin = 'admin' }
|
||||
class Bad { @IsEnum(Role) role!: number }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('accepts an enum rule on the enum field', () => {
|
||||
compiles(`
|
||||
enum Role { Admin = 'admin', User = 'user' }
|
||||
class Ok { @IsEnum(Role) role!: Role }
|
||||
void Ok;`);
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in New Issue
Block a user