Importing one decorator pulled in the message and validator of all 68 — 4,909 bytes instead of 1,837 through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing warned. The library was simply about three times heavier than it needed to be in every consumer's bundle. Every rule is a top-level call: `export const IsString = rule(...)`. rollup proves such a call side-effect-free by reading the factory, which is why it was already emitting 1,823 bytes — and why a single-bundler measurement would have shown no problem at all. esbuild and webpack do not do that analysis and keep the call. Thirty declarations now carry /*#__PURE__*/, which tsc preserves into the ESM emit, and all three bundlers now land within 20 bytes of each other. Measured, minified, esbuild / rollup / webpack: flattenErrors 287 / 292 / 291 one decorator 1837 / 1823 / 1818 validateSync 3722 / 3554 / 3823 toPlainSync 7744 / 7769 / 7832 toInstanceSync 7900 / 7942 / 7956 a typical DTO 10395 / 10402 / 10372 everything 26266 / 25671 / 26879 The serializer and deserializer drop independently — read JSON and you do not pay for writing it. Both mapping entry points keep the validator, because `validate` defaults to true and that is a real reference rather than a missed optimisation. The annotations are a promise to the bundler, so I checked the three factories they cover: rule, pattern and affix each return a closure and touch nothing outside themselves. A false promise here would mean silent deletion in someone else's production build. src/treeshake.test.ts pins the property. It asserts content rather than only bytes — it names the rules that must not appear — and one case asserts everything IS present when everything is used, so a "shaken" result cannot come from a bundle that failed to build. That mattered: two earlier passes at this measurement reported a clean sweep of shaken symbols because rollup had failed to resolve its entry and grep was reading missing files as absence. Strip the annotations and the test fails with `"must be a latitude" should have been shaken out`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
344 lines
11 KiB
Markdown
344 lines
11 KiB
Markdown
# 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.
|
|
|
|
```jsonc
|
|
// tsconfig.json
|
|
{
|
|
"compilerOptions": {
|
|
"experimentalDecorators": false, // was true; Angular does not need it
|
|
"target": "ES2022",
|
|
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
|
|
}
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// 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;
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```ts
|
|
// 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()],
|
|
});
|
|
```
|
|
|
|
```jsonc
|
|
// tsconfig.json
|
|
{
|
|
"compilerOptions": {
|
|
"experimentalDecorators": false,
|
|
"target": "ES2022",
|
|
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
|
|
}
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// 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:
|
|
|
|
```ts
|
|
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`.
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```jsonc
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2022",
|
|
"module": "NodeNext",
|
|
"lib": ["ESNext", "ESNext.Decorators"],
|
|
"experimentalDecorators": false,
|
|
"strictPropertyInitialization": false // optional; `field!: T` otherwise needs the `!`
|
|
}
|
|
}
|
|
```
|
|
|
|
```ts
|
|
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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
```js
|
|
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:*" } }
|
|
```
|
|
|
|
```jsonc
|
|
// 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.
|
|
|
|
```ts
|
|
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:
|
|
|
|
```js
|
|
await esbuild.build({
|
|
target: 'es2022', // ← required, and top-level
|
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
|
});
|
|
```
|
|
|
|
**swc** spells the choice as a proposal date:
|
|
|
|
```jsonc
|
|
// .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 (25.5 KB, 8.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](README.md#bundle-size).
|
|
|
|
```html
|
|
<script type="importmap">
|
|
{ "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
|
|
</script>
|
|
```
|