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 every
rule that implies a type is checked against the field it is attached to, at compile time.
class Order {
@JsonProperty('order_ref')
@IsString() @Matches(/^[A-Z]-\d+$/)
ref!: string;
@IsInt() @Min(1)
quantity!: number;
@IsString()
placedAt!: Date;
total(): number { return this.quantity * 9.99; }
}
const order = fromJsonSync(Order, body); // a real Order
order.total(); // methods intact
ts(1240) Unable to resolve signature of property decorator when called as an expression.
β¦ Type 'Date' is not assignable to type 'string'.
That is the compiler, not cereale β and it is the real thing: the snippet above is
compiled by tsc when this page is built, and the build
fails if it ever stops being an error.
The guarantee
Your rules and your types cannot disagree
A legacy decorator is typed (prototype, propertyName) => void.
Its signature carries nothing about the field's declared type, so a rule that does not fit
the field still compiles. A standard decorator is handed a
ClassFieldDecoratorContext<This, Value>, which does
carry it. cereale uses it.
compiles, then fails at runtime class-validator
class User {
@IsString()
age: number; // accepted by the compiler
}
// ...discovered in production, on a payload
// that happened to carry the wrong shape.
rejected before it runs cereale
class User {
@IsString()
age!: number; // Type 'number' is not
} // assignable to 'string'
// ...caught by your editor, by tsc, and by
// CI. It never gets as far as a payload.
Scalars against scalars
@Min(1) on a string is a compile error, as is @MinLength(2) on a number.
each against arrays
@IsString({ each: true }) demands a string[]; a bare @IsString() on one is rejected.
Classes against classes
@JsonType(() => Address), @JsonSerialize and @IsEnum(E) are all checked against the field.
What you get back
An instance of your class, not a shape that resembles it
Nested objects and arrays you declare with @JsonType, and
subtypes you declare with @JsonPolymorphic, come back as real
classes β so the behaviour you attached to your model survives the trip through JSON. What
you do not declare, cereale leaves alone; it infers nothing.
class Media {
@IsString() title!: string;
}
class Movie extends Media {
@IsInt() @Min(1) duration!: number;
hours() { return this.duration / 60; }
}
class Song extends Media {
@IsString() artist!: string;
}
class Playlist {
@JsonPolymorphic<Media>('type', [
{ value: Movie, name: 'movie' },
{ value: Song, name: 'song' },
])
@ValidateNested({ each: true })
items!: Media[];
}
const list = fromJsonSync(Playlist, body);
const first = list.items[0];
first instanceof Movie // true
if (first instanceof Movie) {
first.hours(); // 2.46β¦ β narrowing
} // works, because it
// really is one.
// Base-class rules reach subclasses, and a
// subclass adds to them rather than
// replacing them.
Playground
The real library, running here
This page bundles cereale itself and compiles what you type with standard decorators. Edit anything and run it.
The compiler here strips types and lowers decorators; it does not type-check. The compile-time
guarantee above is what your editor and tsc give you β a browser
cannot demonstrate an error that stops a build.
Diagnosability
Nothing fails quietly
The 0.3.0 release exists because of this. A mapping layer that loses your data and reports success is worse than one that stops, so every silent failure found in the engine was turned into an error that names the cause and the way out.
A Map used to become {}
Along with Set, RegExp, Error, typed arrays, bigint, symbols and functions β each with its own version of the same silence.
A misconfigured compiler used to throw from inside
TypeError: Cannot convert undefined or null to object, which names neither the cause nor the one-line fix.
A build used to succeed while emitting nothing that runs
Vite 8 passes decorator syntax straight through. The bundle builds; the first import throws.
JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON.
Give the property a @JsonSerialize() serializer that converts it, or drop it from
the output with @JsonIgnore().
TypeError: cereale needs TC39 standard decorators, but the compiler emitted legacy
ones. Set "experimentalDecorators": false in tsconfig.json (and drop
"emitDecoratorMetadata").
JsonMappingError: Circular reference detected during serialization at child.parent.
Break the cycle with @JsonIgnore() on the back-reference, or supply a
@JsonSerialize() serializer for that property.
Before you install
The toolchain cost, stated plainly
cereale reads the metadata that only a standard decorator transform emits, so which compiler you use decides whether it works at all. The three β rows are executed by a test on every CI run rather than asserted here β each one compiles a decorated class with that tool and checks the metadata arrived. The β row cannot be: oxc ships inside a native binary with no standalone transform API, so it was established by hand, and the plugin below is what came of it.
| Transformer | Works | Setting |
|---|---|---|
tsc | β | experimentalDecorators: false, target: ES2022+ |
| esbuild | β | experimentalDecorators: false via tsconfigRaw, plus esbuild's own top-level target: es2022 β its default esnext leaves the decorators in place |
| swc | β | jsc.transform.decoratorVersion: "2022-03" |
| oxc | β | used by Vite 8 and Vitest 4 β see below |
vitest prints 0 test next to a bare
SyntaxError, and vite build
reports success while emitting a bundle that throws on first import. cereale ships the plugin
that fixes it.
import { defineConfig } from 'vite';
import { standardDecorators } from 'cereale/vite';
export default defineConfig({
plugins: [standardDecorators()],
});
It transforms .ts, .mts and
.cts outside node_modules with
esbuild, falling back to the TypeScript compiler β cereale depends on neither. Decorated
classes in .tsx need an include
of your own; they are excluded by default because lowering them means also deciding what
happens to the JSX. Nothing in the plugin is specific to cereale; it can be deleted once oxc
implements the transform.
Getting started
Two settings, and one caveat
tsconfig.json what the compiler needs
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false
}
}
No reflect-metadata, and
emitDecoratorMetadata is never read. If
experimentalDecorators is on, cereale says so by name
instead of failing somewhere inside itself.
not on npm yet installing it today
git clone https://github.com/Avalon-Vanguard/cereale
cd cereale
npm install && npm run build
npm pack # β cereale-0.3.0.tgz
# then, from your own project
npm install ../cereale/cereale-0.3.0.tgz
npm install cereale does not resolve to
this library β the name is unclaimed on the registry. Installing straight from GitHub
will not work either: the build output is not committed, so the package would arrive
without its dist/.
Reference
Everything cereale exports
Every rule that implies a type is checked against the field it decorates β a few
deliberately do not, because they fit any field
(@IsDefined) or because the constraint is negative and
narrowing it would be backwards (@IsNotIn). All of them take
{ each: true } to run per element of an array, and a
message, reported verbatim.
Honest limits
What cereale is not
It does not infer types from a schema
You write the field type and the rule; cereale guarantees they agree. If you want the type derived from a schema, that is Zod's design, and Zod is the right tool for it.
It cannot coexist with legacy decorators
The two decorator systems are a whole-program setting. A project that still needs
experimentalDecorators for another library cannot use cereale yet.
Rules live on the class, not on the data
validate() on a plain object reports nothing. Validate the
instance you get back from toInstance.
Renaming is not backwards-compatible by itself
Once a property carries @JsonProperty, its original name
no longer reaches it β it is refused rather than copied onto the instance behind the
rename's back. Add @JsonAlias to keep older clients
working. Under unknownKeys: 'error' the stale name is
reported by name, along with what the property is called now.
abstract and accessor fields cannot be decorated
Standard decorators do not apply to abstract members, and an
accessor field keeps its value in a private slot that mapping
cannot reach. Both are errors rather than silent no-ops.
It is not on npm yet
0.3.0 lives in the repository. npm install cereale does not
resolve to this library β build it from source until it is published.