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.

0 runtime dependencies 68 decorators TC39 standard decorators ESM + CJS + single-file Node ≥20 1.8 KB for one decorator
order.ts
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

with legacy decorators
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

with standard decorators
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.

catalogue.ts
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[];
}
what comes out
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.

playground.ts
output

      

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.

what you get instead
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 support for TC39 standard decorators
TransformerWorksSetting
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
On Vite 8 or Vitest 4? oxc leaves decorator syntax in the output and reports nothing: 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 support
FrameworkWorksWhat 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.

vite.config.ts
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

tsconfig.json
{
  "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

shell
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.

index.html
<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.

Minified bytes by import, across three bundlers
What you import esbuildrollupwebpack
flattenErrors394367394
one decorator1,8371,8231,818
validateSync3,7223,5543,738
toPlainSync7,7447,7697,771
toInstanceSync7,9007,9427,944
a typical DTO10,39510,40210,360
the whole library26,26625,67126,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.