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 existed 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 in the first table 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 5.2+ | ✓ | 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.
| Framework | Works | What it takes |
|---|---|---|
| Angular 21 | ✓ | Flip the scaffolded experimentalDecorators to false — Angular does not need it |
| React, Vue, Svelte… | ✓ | Any Vite 8 app — add the plugin below |
| Bun 1.3 | ✓ | Nothing |
Node + tsc | ✓ | Just the flag |
| Next.js 16 | ~ | Not inline — keep models in a package compiled by tsc |
| NestJS 11.1 | ~ | Not inline — its DI needs emitDecoratorMetadata; same precompiled route |
Each of these was set up and run before it was written down, on 2026-08-05, against the versions listed. FRAMEWORKS.md has the full recipe for every one, including the two that need the precompiled route.
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, one caveat, and two ways in
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.
on npm installing it
npm install cereale
Published from CI with provenance, so the registry carries a verified attestation linking the tarball to the commit it was built from — visible on the package page.
No bundler at all
cereale/min is the whole library flattened into one
minified ES module — 33.9 KB, 9.6 KB gzipped — for import maps,
a bare <script type="module">, Deno and Workers.
<script type="importmap">
{ "imports": { "cereale": "./node_modules/cereale/dist/cereale.min.js" } }
</script>
<script type="module">
import { IsString, toInstanceSync } from 'cereale';
</script>
If you are using a bundler, prefer the default entry. The flat file keeps its purity annotations, so a bundler can still drop the rules you did not import — that is pinned by a test — but the per-module build shakes slightly leaner (1,837 bytes against 1,996 for one decorator) and is the canonical route.
Bundle size
You pay for the decorators you name
68 decorators is a lot to ship to a browser, so none of the ones you did not import are shipped. Minified bytes, measured through all three bundlers.
| What you import | esbuild | rollup | webpack |
|---|---|---|---|
flattenErrors | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 |
validateSync | 3,722 | 3,554 | 3,738 |
toPlainSync | 7,744 | 7,769 | 7,771 |
toInstanceSync | 7,900 | 7,942 | 7,944 |
| a typical DTO | 10,395 | 10,402 | 10,360 |
| the whole library | 26,266 | 25,671 | 26,879 |
Reading and writing drop independently
Import toInstanceSync and you do not pay for the
serializer. The validator stays in both, because
validate defaults to
true — a real reference, not a missed optimisation.
It did not come for free
Every rule is a top-level call. rollup proves such a call side-effect-free by reading
the factory; esbuild and webpack will not. Without a
/*#__PURE__*/ on each, one decorator cost
4,909 bytes instead of 1,837. Nothing failed — the library was just
three times heavier in every bundle, and the only way to find out was to measure.
rollup alone would have shown nothing
It was already producing 1,823 bytes and hid the problem. That is the argument for measuring through more than one bundler, and it is why a test now pins the property rather than the prose.
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 still 0.x
0.4.1 is published, and under semver a 0.x minor bump is allowed to break you. Pin the version until 1.0; the changelog says what moved and why.