✨ 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:
@@ -1,17 +1,58 @@
|
||||
# Cereale
|
||||
|
||||
Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support.
|
||||
**Validated domain objects, not validated data.**
|
||||
|
||||
Cereale maps JSON onto your own classes and gives you back real instances — with your methods,
|
||||
your inheritance, your `instanceof` checks — and type-checks the validation rules against the
|
||||
fields they are attached to. Zero runtime dependencies.
|
||||
|
||||
```typescript
|
||||
class User {
|
||||
@JsonProperty('display_name')
|
||||
@IsString() @MinLength(2)
|
||||
displayName!: string;
|
||||
|
||||
@IsInt() @Min(0)
|
||||
age!: number;
|
||||
|
||||
@IsString()
|
||||
age2!: number; // ← compile error: Type 'number' is not assignable to type 'string'
|
||||
|
||||
greet() { return `Hi ${this.displayName}`; }
|
||||
}
|
||||
|
||||
const user = fromJsonSync(User, body); // a real User
|
||||
user.greet(); // your methods are still there
|
||||
```
|
||||
|
||||
## Why not Zod?
|
||||
|
||||
Zod is excellent, and if a plain validated object is what you want, use it. The difference is
|
||||
what you get back:
|
||||
|
||||
| | Zod | Cereale |
|
||||
| --- | --- | --- |
|
||||
| Result of parsing | an anonymous object matching a schema | an instance of **your class** |
|
||||
| Methods, getters, inheritance | none — data only | preserved |
|
||||
| Where the type comes from | inferred from the schema | your class declaration |
|
||||
| Rules checked against the type | not applicable — schema *is* the type | **yes, at compile time** |
|
||||
| Bidirectional mapping (renaming both ways) | not the focus | first-class |
|
||||
|
||||
Cereale does not infer your type from a schema, so you still write the field type and the rule.
|
||||
What it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not
|
||||
compile. That is the guarantee class-validator has never offered.
|
||||
|
||||
Reach for Cereale when your domain model is already a class — a NestJS provider, a TypeORM
|
||||
entity, anything with behaviour attached. Reach for Zod when you just want the data.
|
||||
|
||||
## Features
|
||||
|
||||
- **Spring-like Decorators:** Familiar `@JsonProperty`, `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
|
||||
- **Field-name Mapping:** Map `first_name` to `firstName` per property or with a naming strategy.
|
||||
- **Access Control:** Keep passwords out of responses and server-owned ids out of requests.
|
||||
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
|
||||
- **Polymorphism Support:** Native handling of polymorphic types via discriminators.
|
||||
- **Integrated Validation:** 50+ validation decorators, applied during mapping or on demand.
|
||||
- **Type Safety:** Fully written in TypeScript for excellent developer experience.
|
||||
- **Zero Dependencies:** Extremely lightweight and fast.
|
||||
- **Strongly typed decorators:** a rule that does not fit its field is a compile error.
|
||||
- **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes.
|
||||
- **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions.
|
||||
- **Access control:** keep passwords out of responses and server-owned ids out of requests.
|
||||
- **Sync and async:** every entry point has a synchronous twin.
|
||||
- **Zero dependencies**, ESM + CJS, Node 20+.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -19,109 +60,92 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
|
||||
npm install cereale
|
||||
```
|
||||
|
||||
Enable `experimentalDecorators` in your `tsconfig.json`:
|
||||
Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` flag:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"target": "ES2022"
|
||||
"target": "ES2022",
|
||||
"lib": ["ESNext", "ESNext.Decorators"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cereale stores its own metadata, so `reflect-metadata` is not required and
|
||||
`emitDecoratorMetadata` is not read. Requires Node 20 or later.
|
||||
Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and
|
||||
`emitDecoratorMetadata` is not read.
|
||||
|
||||
> **Toolchain note.** Standard decorators are transformed by `tsc` and by esbuild (so Vite
|
||||
> works). They are **not** yet transformed by oxc; if your toolchain uses it, decorator syntax
|
||||
> will fail to parse. v1.x, which uses legacy decorators, remains available for those setups.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Define your Models
|
||||
### 1. Define your model
|
||||
|
||||
```typescript
|
||||
import {
|
||||
IsString,
|
||||
IsDate,
|
||||
ValidateNested,
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
JsonSerializer,
|
||||
JsonDeserializer
|
||||
IsString, IsDate, IsInt, Min, ValidateNested, JsonType,
|
||||
JsonProperty, JsonWriteOnly, JsonPolymorphic,
|
||||
} from 'cereale';
|
||||
|
||||
// Custom Date Serializer
|
||||
class DateSerializer implements JsonSerializer<Date, string> {
|
||||
serialize(value: Date): string {
|
||||
return value.toISOString().split('T')[0]!;
|
||||
}
|
||||
}
|
||||
class Address {
|
||||
@IsString() street!: string;
|
||||
@IsString() city!: string;
|
||||
|
||||
class DateDeserializer implements JsonDeserializer<string, Date> {
|
||||
deserialize(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
format() { return `${this.street}, ${this.city}`; }
|
||||
}
|
||||
|
||||
abstract class Media {
|
||||
@IsString()
|
||||
abstract type: string;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
// Standard decorators cannot decorate an `abstract` member, so declare it concretely.
|
||||
@IsString() type: string = '';
|
||||
@IsString() title: string = '';
|
||||
}
|
||||
|
||||
class Book extends Media {
|
||||
type = 'book';
|
||||
@IsString() override type = 'book';
|
||||
@IsString() author!: string;
|
||||
|
||||
@IsString()
|
||||
author: string;
|
||||
|
||||
@JsonSerialize(DateSerializer)
|
||||
@JsonDeserialize(DateDeserializer)
|
||||
@JsonProperty('published_at')
|
||||
@IsDate()
|
||||
publishedAt: Date;
|
||||
publishedAt!: Date;
|
||||
}
|
||||
|
||||
class Library {
|
||||
@IsString()
|
||||
name: string;
|
||||
@IsString() name!: string;
|
||||
|
||||
@ValidateNested() @JsonType(() => Address)
|
||||
address!: Address; // the class must match the field
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@JsonPolymorphic('type', [
|
||||
{ value: Book, name: 'book' }
|
||||
])
|
||||
items: Media[];
|
||||
@JsonPolymorphic<Media>('type', [{ value: Book, name: 'book' }])
|
||||
items!: Media[];
|
||||
}
|
||||
```
|
||||
|
||||
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s
|
||||
`@IsString() title` as well as its own rules.
|
||||
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s rules as
|
||||
well as its own, and re-stating a rule on an override does not report it twice.
|
||||
|
||||
### 2. Map JSON with Validation
|
||||
### 2. Map JSON, synchronously or not
|
||||
|
||||
```typescript
|
||||
import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale';
|
||||
import { fromJsonSync, toJsonSync, JsonValidationError, flattenErrors } from 'cereale';
|
||||
|
||||
async function main() {
|
||||
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
|
||||
|
||||
try {
|
||||
// Deserialize JSON to Class Instance
|
||||
const library = await fromJson(Library, json);
|
||||
console.log(library.name); // "Central Library"
|
||||
console.log(library.items[0] instanceof Book); // true
|
||||
|
||||
// Serialize Class Instance back to JSON
|
||||
console.log(await toJson(library));
|
||||
} catch (error) {
|
||||
if (error instanceof JsonValidationError) {
|
||||
console.error(flattenErrors(error.errors));
|
||||
// { "items[0].title": ["title must be a string"] }
|
||||
}
|
||||
try {
|
||||
const library = fromJsonSync(Library, json);
|
||||
library.address.format(); // your method, on a real Address
|
||||
library.items[0] instanceof Book; // true
|
||||
console.log(toJsonSync(library));
|
||||
} catch (error) {
|
||||
if (error instanceof JsonValidationError) {
|
||||
console.error(flattenErrors(error.errors));
|
||||
// { "items[0].title": ["title must be a string"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every function has an async form too (`fromJson`, `toJson`, …) for when a serializer,
|
||||
deserializer or validator of yours returns a Promise.
|
||||
|
||||
### 3. Modern Web Frameworks (Request Integration)
|
||||
|
||||
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
||||
@@ -401,6 +425,13 @@ dominant cost.
|
||||
|
||||
## Notes and Limitations
|
||||
|
||||
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
||||
cereale guarantees they agree. If you want the type derived from a schema, that is Zod's
|
||||
model, not this one.
|
||||
- **`abstract` fields cannot be decorated.** Standard decorators do not apply to abstract
|
||||
members. Declare the field concretely in the base class instead.
|
||||
- **oxc does not transform standard decorators yet.** `tsc` and esbuild do.
|
||||
|
||||
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
||||
the cycle with `@JsonIgnore()` on the back-reference.
|
||||
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
|
||||
|
||||
Reference in New Issue
Block a user