An audit of the last commit found two real problems and several claims of mine that went further than the evidence. **cereale/min was not tree-shakable.** scripts/build-bundle.mjs used esbuild's `minify: true`, whose minifyWhitespace pass strips comments — /*#__PURE__*/ annotations included. The published entry point therefore reproduced exactly the bug the previous commit fixed: one decorator came out at 5,066 bytes with all 26 unrelated rule messages, against 1,837 from the per-module entry. Every source-level check stayed green, because they all bundled src/ and the annotations are stripped on the way into dist/. It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw and 9.6 KB gzipped against 26.0/8.7, so about a kilobyte over the wire for a file that behaves correctly however it is used. One decorator via cereale/min is now 1,996 bytes. The build asserts the annotation count survives, and treeshake.test.ts now bundles the published artifact as well as the source — the gap that let this through. **sideEffects was partly inert.** `./dist/cjs/metadata.js` could never match: the build writes dist/cjs/package.json, which becomes the nearest descriptor for everything beneath it, so bundlers read sideEffects from there. That file now carries its own declaration. `./src/metadata.ts` was missing while src/ is published, which declared the Symbol.metadata install droppable in the source tree. Five module-scope caches in utils.ts are annotated for the same reason as the rules. I checked the audit's third blocker — that the Symbol.metadata install is dropped by bundlers — and it is not. It survives every case where it is load-bearing (a decorator import, toPlainSync, modelOf, and a decorated model bundled with an app). It is dropped only when importing nothing but flattenErrors, which needs no metadata, so that is correct. Corrections to my own wording: - "all three bundlers land within 20 bytes" held only for the one-decorator row; larger imports differ by up to a few hundred bytes - "measured through three bundlers, and pinned by a test" read as though the test covered all three; it covers esbuild, on source and on dist - "every rule is a top-level call" — 30 of the 68 are - the docs page "loads nothing from the network" — it fetches its own vendored compiler, same-origin, on first Run. It has no third-party dependencies, which is the claim I should have made - cereale/min's size, everywhere it appears Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
11 KiB
Using cereale with your framework
Every recipe here was run before it was written. Where something does not work, this says so rather than offering a workaround that has not been tried.
The only requirement
cereale reads the metadata that a TC39 standard decorator transform emits. That means your
compiler must have experimentalDecorators off — which is the default in TypeScript 5.0
and later, but not what several frameworks scaffold.
There is no partial credit and no per-file override: experimentalDecorators is a property of
a TypeScript program, so every decorator in one compilation uses the same system. If the flag
is on, tsc refuses cereale's decorators outright:
error TS1240: Unable to resolve signature of property decorator when called as an expression.
Argument of type 'User' is not assignable to parameter of type 'undefined'.
and if you skip type-checking (esbuild, swc and Babel all strip types without checking them), the emit reaches cereale as a legacy call and it says so by name rather than failing obscurely.
Two ways to adopt it
Inline — write cereale decorators directly in your app. Needs experimentalDecorators: false.
This is what you want, and most toolchains allow it.
A precompiled models package — when the program is committed to legacy decorators for
something else (NestJS's DI, TypeORM entities, Next's SWC pipeline), put your cereale classes in
a separate package compiled by tsc, and import the built output. The decorators run at class
definition time inside that package; your app only ever sees plain JavaScript, so its own
decorator setting is irrelevant. Verified working inside a program with both
experimentalDecorators: true and emitDecoratorMetadata: true.
You keep the compile-time guarantee where it matters — in the package where the rules are written — and lose nothing at runtime.
Support
Verified on the versions listed, on 2026-08-05. ✅ inline, ⚠️ via a precompiled package. Rows marked — were not tested; they are listed so their absence is not mistaken for a verdict.
| Toolchain | Notes | |
|---|---|---|
tsc 5.2+ |
✅ | experimentalDecorators: false, target: ES2022+ |
| Angular 21 | ✅ | flip the scaffolded experimentalDecorators to false — Angular does not need it |
| Vite 8 | ✅ | add cereale/vite; covers React, Vue, Svelte, Solid, Qwik, Astro, Nuxt, SvelteKit |
| Vitest 4 | ✅ | same plugin |
| Bun 1.3 | ✅ | works with no configuration |
| esbuild 0.25 | ✅ | top-level target: es2022+ and experimentalDecorators: false |
| swc 1.15 | ✅ | jsc.transform.decoratorVersion: "2022-03" |
| Next.js 16 | ⚠️ | no inline support — see below |
| NestJS 11.1 | ⚠️ | its DI needs emitDecoratorMetadata — see below |
| Deno | — | untested |
webpack + ts-loader |
— | untested; follows whatever tsc is configured to do |
Angular
The surprise is that Angular works. The CLI scaffolds "experimentalDecorators": true, but
Angular's compiler does not need it — ngtsc erases @Component and @Injectable into static
properties itself, rather than relying on TypeScript's decorator emit. Turn the flag off and
both work in the same program.
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": false, // was true; Angular does not need it
"target": "ES2022",
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
}
}
// user.dto.ts
import { IsString, MinLength, IsInt, Min, JsonProperty } from 'cereale';
export class UserDto {
@JsonProperty('display_name')
@IsString() @MinLength(3)
displayName!: string;
@IsInt() @Min(18)
age!: number;
}
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs';
import { toInstanceSync } from 'cereale';
import { UserDto } from './user.dto';
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
load(id: string) {
return this.http.get(`/api/users/${id}`).pipe(map(body => toInstanceSync(UserDto, body)));
}
}
Verified with ngc on Angular 21.2 and strictTemplates: true: the component's template is
still type-checked, and a wrong cereale rule is still a compile error inside the Angular build —
@IsString() on age!: number fails with TS1240 exactly as it does anywhere else.
Any Vite app — React, Vue, Svelte, Solid, Astro, Nuxt, SvelteKit
Vite 8 transforms with oxc, which does not implement the standard decorator transform and does
not report that. vite build succeeds and leaves the decorator syntax in the bundle, which
throws the moment anything imports it. cereale ships the plugin that fixes it.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; // or vue(), svelte(), solid()…
import { standardDecorators } from 'cereale/vite';
export default defineConfig({
plugins: [standardDecorators(), react()],
});
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": false,
"target": "ES2022",
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
}
}
// UserForm.tsx
import { useState } from 'react';
import { toInstanceSync, validateSync, flattenErrors } from 'cereale';
import { UserDto } from './user.dto'; // a .ts file, not .tsx — see below
export function UserForm() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
function onSubmit(form: FormData) {
const draft = toInstanceSync(UserDto, Object.fromEntries(form), { validate: false });
setErrors(flattenErrors(validateSync(draft)));
}
// …
}
The plugin transforms .ts, .mts and .cts outside node_modules. .tsx is excluded by
default, because lowering decorators there means also deciding what happens to the JSX — keep
decorated classes in .ts files, or pass an include of your own:
standardDecorators({ include: (id) => /\.[cm]?tsx?$/.test(id) && !id.includes('/node_modules/') })
Options: include, target (default es2022), and transformer ('auto' prefers esbuild and
falls back to the TypeScript compiler; cereale depends on neither).
Vitest
Same plugin, same reason — Vitest 4 uses the same oxc pipeline, and without it prints 0 test
next to a bare SyntaxError.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { standardDecorators } from 'cereale/vite';
export default defineConfig({ plugins: [standardDecorators()] });
Node, with tsc
Nothing to configure beyond the flag.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false,
"strictPropertyInitialization": false // optional; `field!: T` otherwise needs the `!`
}
}
import express from 'express';
import { fromJsonSync, JsonValidationError, flattenErrors } from 'cereale';
import { UserDto } from './user.dto.js';
app.post('/users', (req, res) => {
try {
const user = fromJsonSync(UserDto, JSON.stringify(req.body));
return res.status(201).json(user);
} catch (error) {
if (error instanceof JsonValidationError) {
return res.status(400).json({ errors: flattenErrors(error.errors) });
}
throw error;
}
});
Bun
Works with no configuration — Bun's transpiler emits standard decorators when
experimentalDecorators is off, which is its default.
bun add cereale
bun run app.ts
Next.js
Not supported inline. Next derives both the SWC parser's decorator support and its
transform mode from the single experimentalDecorators flag:
const enableDecorators = Boolean(jsConfig?.compilerOptions?.experimentalDecorators);
// parser: decorators: enableDecorators
// transform: legacyDecorator: enableDecorators
So the flag on gives legacy emit that cereale refuses, and the flag off makes @ a syntax
error. There is no third setting, and no exposed decoratorVersion option.
Use a precompiled models package instead:
repo/
models/ ← compiled by tsc, experimentalDecorators: false
package.json { "name": "@acme/models", "exports": { ".": "./dist/index.js" } }
tsconfig.json
src/user.dto.ts ← your cereale classes live here
web/ ← the Next app, untouched
package.json { "dependencies": { "@acme/models": "workspace:*" } }
// models/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false,
"useDefineForClassFields": true,
"declaration": true,
"outDir": "dist"
}
}
Build models before web. Next only ever sees the compiled JavaScript — no decorator syntax
reaches its parser — and mapping and validation work normally. Verified against Next 16.3's own
SWC configuration.
NestJS
Not supported inline. Nest scaffolds experimentalDecorators: true and
emitDecoratorMetadata: true, and its dependency injection genuinely needs the design:type
metadata that only legacy decorators emit. Turning the flag off breaks Nest.
The precompiled models package above works here too, and this is the case that proves the
pattern: a program compiled with both legacy flags on can import cereale DTOs from a
precompiled package and validate with them normally, alongside its own @Injectable() and
@Inject() decorators.
import { Injectable, BadRequestException } from '@nestjs/common';
import { UserDto } from '@acme/models'; // precompiled
import { toInstanceSync, validateSync, formatErrors } from 'cereale';
@Injectable()
export class UsersService {
create(body: unknown) {
const draft = toInstanceSync(UserDto, body, { validate: false });
const errors = validateSync(draft);
if (errors.length) throw new BadRequestException(formatErrors(errors));
return draft;
}
}
If you would rather not split the package, stay on class-validator for now — Nest's own
ValidationPipe is built around it, and cereale does not try to replace that integration.
Other bundlers
esbuild needs its own top-level target, not one inside tsconfigRaw. A target in
tsconfigRaw sets the useDefineForClassFields default and nothing else, so the decorators
are left in the output:
await esbuild.build({
target: 'es2022', // ← required, and top-level
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
});
swc spells the choice as a proposal date:
// .swcrc
{
"jsc": {
"parser": { "syntax": "typescript", "decorators": true },
"transform": { "decoratorVersion": "2022-03" },
"target": "es2022"
}
}
A single file, no bundler
cereale/min is the whole library flattened into one minified ES module (33.9 KB, 9.6 KB
gzipped) for import maps, <script type="module">, Deno and Workers.
If you are using a bundler, do not use it. It is the whole library in one file, so nothing can be dropped from it. The default entry point tree-shakes — one decorator costs about 1.8 KB against 26 KB for everything — and produces a smaller result in any real application. See Bundle size.
<script type="importmap">
{ "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
</script>