Files
cereale/FRAMEWORKS.md
T
Claude 4b910f19bf 🔍 fix: apply the code review — two visible regressions, and the gates behind them
Nine review angles, fourteen verified findings, all but the byte-table
generator applied. The two that mattered most were regressions of mine:

- a:hover repainted button-styled anchors, and in dark theme --accent-text
  equals --accent-solid — hovering the hero CTA drew its label in its own
  background colour. Verified invisible before (computed color == computed
  background) and distinct after: 10.39:1 dark, 6.90:1 light. The buttons and
  the skip link now re-assert their label colours on hover.
- Footer links had lost every non-hover affordance: the text-decoration:none
  carve-out plus body-coloured links left a 2.37:1 shade difference as the
  only cue. The carve-out is deleted — .nav-links a and .brand already
  declare none themselves — and the accent rule is back on the footer.

Also on the page: #ref-filter, the one text input, moves to --border-ui (it
still had the 1.68:1 border the token's own comment calls decorative);
focusable code surfaces get the --accent-on-code ring at -2px offset, inside
the .code overflow clip; #output .out-err drops #ff8095, the last surviving
colour of the deleted indigo palette; the two rgba(255,106,126) washes become
color-mix over --err-line so a grep for the token finds them.

The theme machinery loses a whole block: the dark media query is guarded with
:not([data-theme="light"]), so an explicit light toggle falls through to the
bare :root palette and the 21-token hand-copy in [data-theme="light"] is
gone. Verified in all four system/toggle combinations.

The page stops contradicting the repo: it claimed cereale/min "cannot
tree-shake — nothing left to shake" while src/treeshake.test.ts proves the
opposite on every run. Corrected here and in FRAMEWORKS.md, with the measured
figures (1,837 vs 1,996 bytes for one decorator). The release facts the page
was hand-bumping — both tgz names, "0.4.0 lives in the repository", the
sixty-eight — now fill from meta.js/the bundle like the version badge always
has.

The workflows close three holes:

- The docs sync gate was blind to NEW untracked build outputs (git diff does
  not report them; demonstrated). Both workflows now run check:docs-sync, one
  shared script that fails on anything porcelain reports — which also ends
  the copy-paste divergence between them.
- pages.yml deploys on CI succeeding on main (workflow_run) instead of on the
  push itself, so a deploy implies green tests, not just in-sync docs. The
  deploy job refuses refs other than main, closing the workflow_dispatch
  any-branch deploy, and the build job drops pages/id-token — npm postinstall
  scripts no longer run alongside an OIDC grant.
- The Junie action is pinned to the commit behind v1.7.4 rather than the tag,
  which is the immutability the previous comment promised but a mutable ref
  cannot deliver.

Verified: every fix confirmed in a rendered browser in both themes; 72
contrast pairs still pass; no overflow at 20 widths; 268 tests, build,
check:types, check:docs, actionlint all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-17 16:23:46 +00:00

12 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, prefer the default entry. The flat file keeps its /*#__PURE__*/ annotations, so a bundler can still drop the rules you did not import — pinned by src/treeshake.test.ts — but the per-module build shakes slightly leaner (1,837 bytes against 1,996 for one decorator through esbuild) and is the canonical route. See Bundle size.

<script type="importmap">
  { "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
</script>