Files
cereale/docs/index.html
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

1029 lines
50 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cereale — validated domain objects, not validated data</title>
<meta name="description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks the validation rules against the fields they are attached to, at compile time.">
<meta name="color-scheme" content="light dark">
<link rel="canonical" href="https://avalon-vanguard.github.io/cereale/">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 18'><rect x='4' width='2' height='18' fill='%23a0784a' opacity='.35'/><rect y='2' width='10' height='2' fill='%23a0784a'/><rect y='6' width='10' height='2' fill='%23a0784a'/><rect y='10' width='10' height='2' fill='%23a0784a'/><rect y='14' width='10' height='2' fill='%23a0784a'/></svg>">
<!-- Link previews. No og:image: a preview card with a broken image is worse than one
without, and there is no artwork to point at yet. -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://avalon-vanguard.github.io/cereale/">
<meta property="og:site_name" content="cereale">
<meta property="og:title" content="cereale — validated domain objects, not validated data">
<meta property="og:description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks every rule against the field it is attached to, at compile time.">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="cereale — validated domain objects, not validated data">
<meta name="twitter:description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Rules are checked against fields at compile time.">
<style>
/* ---------------------------------------------------------------- tokens */
/* Chaff: one paper, one ink, one repeated 2px mark. The accent is rye-brown and
deliberately NOT wheat-gold — gold is both the cliche and a collision with --warn,
which already owns amber. Two amber families would make "our brand" and "needs
your attention" the same colour.
Every token below that differs between themes must be written in THREE places: this
block (the light palette), the prefers-color-scheme block, and [data-theme="dark"].
The dark media block is guarded with :not([data-theme="light"]), so an explicit light
toggle falls straight through to these values — there is no light copy to keep in sync. */
:root {
--bg: #faf7f0;
--bg-raised: #fffdf7;
--bg-sunken: #f2ede1;
--text: #1a1712;
--text-muted: #5c5346;
--text-faint: #6e6353;
--border: #e6dfd1;
--border-strong: #cfc5b2;
/* Icon-only controls need 3:1 against the page (WCAG 1.4.11); --border-strong is
decorative and sits well below it. .icon-btn and .btn-secondary use this instead. */
--border-ui: #8e8269;
--accent: #8a6238;
--accent-text: #7a5530;
--accent-soft: #f1e9dc;
/* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent
for legibility against the page, which then leaves white text on it below AA. */
--accent-solid: #6b4a28;
--on-accent: #fffdf7;
--bad: #a82820;
--bad-soft: #faebe7;
--ok: #256b3d;
--ok-soft: #e8f2e9;
--warn: #8a5a05;
--warn-soft: #fbf1dc;
/* Code surfaces stay dark in both themes: one syntax palette, always legible.
Warmed off the blue-grey axis so the slab does not read cold against oat paper. */
--code-bg: #14120c;
--code-bg-raised: #1c190f;
--code-border: #2e2818;
--code-text: #e2dccb;
--code-faint: #9a9280;
/* The accent is unreadable on the dark slab in light mode, so focus rings drawn on a
code surface get their own token. Without it #editor:focus measures 2.86:1 — a live
1.4.11 failure on the shipped page. This measures 6.96:1. */
--accent-on-code: #c9962f;
/* The error line is the page's one visual device; these were three loose literals. */
--err-line: #ff6a7e;
--err-text: #ffb3c0;
--t-comment: #8a93b8;
--t-string: #b8e08a;
--t-keyword: #c792ea;
--t-decorator: #82aaff;
--t-type: #ffcb6b;
--t-number: #f78c6c;
/* The whole ornament: a 2px mark every 9px. var() resolves against the winning
cascaded value, so --accent changing with the theme retints these automatically —
they belong on this block only, never in the three below. */
--grain-mark: 2px;
--grain-pitch: 9px;
--rule-x: repeating-linear-gradient(90deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
--rule-y: repeating-linear-gradient(180deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
--rule-code: repeating-linear-gradient(90deg, var(--code-faint) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
--radius: 10px;
--radius-lg: 16px;
--shadow: 0 1px 2px rgba(40, 30, 14, .05), 0 8px 24px -12px rgba(40, 30, 14, .20);
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--measure: 64ch;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #12100b;
--bg-raised: #1a1710;
--bg-sunken: #16130d;
--text: #ede7da;
--text-muted: #aba292;
--text-faint: #9b9280;
--border: #29241a;
--border-strong: #3b3427;
--border-ui: #736a59;
--accent: #c4a97c;
--accent-text: #d8be94;
--accent-soft: #2a2115;
--accent-solid: #d8be94;
--on-accent: #17120a;
--bad: #f2867e;
--bad-soft: #2b1512;
--ok: #6fc98c;
--ok-soft: #12241a;
--warn: #e9b45a;
--warn-soft: #261d0c;
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
}
}
/* An explicit light choice: the guarded media block above no longer matches, so the
bare :root palette wins on its own. Only the UA hint needs stating. */
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #12100b; --bg-raised: #1a1710; --bg-sunken: #16130d;
--text: #ede7da; --text-muted: #aba292; --text-faint: #9b9280;
--border: #29241a; --border-strong: #3b3427; --border-ui: #736a59;
--accent: #c4a97c; --accent-text: #d8be94; --accent-soft: #2a2115;
--accent-solid: #d8be94; --on-accent: #17120a;
--bad: #f2867e; --bad-soft: #2b1512; --ok: #6fc98c; --ok-soft: #12241a;
--warn: #e9b45a; --warn-soft: #261d0c;
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
color-scheme: dark;
}
/* ----------------------------------------------------------------- base */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 5rem; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 16px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* 680 and 640 are fiction on a non-variable system stack — they round to 700. And -.035em
is generic-landing-page tracking; it is most of what made this look like every other
dev-tool site. */
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 700; letter-spacing: -.012em; }
h1 { font-size: clamp(2rem, 1.35rem + 2.8vw, 3rem); line-height: 1.14; letter-spacing: -.018em; }
h2 { font-size: clamp(1.4rem, 1.1rem + 1.3vw, 1.85rem); letter-spacing: -.012em; }
h3 { font-size: 1.0625rem; letter-spacing: -.008em; }
p { margin: 0 0 1rem; }
/* Links keep body colour and are marked by a rule instead. On a palette this warm,
--accent-text and --text-muted sit 1.14:1 apart — colour alone would lose them. */
a { color: var(--text); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: .16em; }
a:hover { color: var(--accent-text); text-decoration-color: currentColor; }
code, kbd, pre { font-family: var(--mono); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.wrap { width: min(1120px, 100% - 2.5rem); margin-inline: auto; }
/* Grid and flex children default to min-width:auto, so one wide code block stretches its
whole column past the viewport and takes the prose next to it along. */
.hero-grid > *, .grid > *, .compare > *, .pg-grid > *, .ref-groups > *, .foot-grid > * { min-width: 0; }
section { padding-block: clamp(3rem, 6vw, 5.5rem); }
.lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); }
.eyebrow {
font-family: var(--mono);
font-size: .6875rem; font-weight: 500; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent-text); margin: 0 0 .7rem;
}
.section-head { margin-bottom: 2.25rem; }
/* The ornament, use 1 of 3: a section begins. The hero is the page beginning, not a
section, so it deliberately has no .section-head and no mark. */
.section-head::before {
content: ""; display: block; width: 4.5rem; height: 3px;
margin-bottom: .95rem; background-image: var(--rule-x);
}
.skip {
position: absolute; left: -9999px; top: 0; z-index: 100;
background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
}
.skip:focus { left: 0; }
.skip:hover { color: var(--on-accent); }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* ------------------------------------------------------------------ nav */
header.nav {
position: sticky; top: 0; z-index: 50;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(150%) blur(10px);
border-bottom: 1px solid var(--border);
}
.nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; }
.brand { display: flex; align-items: center; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; }
.brand .mark { color: var(--accent); flex: none; }
.badge {
font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px;
background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
}
.nav-links { margin-left: auto; display: flex; align-items: center; gap: .35rem; }
.nav-links a {
color: var(--text-muted); text-decoration: none; padding: .4rem .6rem; border-radius: var(--radius); font-size: .9375rem; font-weight: 500;
}
.nav-links a:hover { color: var(--text); background: var(--bg-sunken); }
.nav-links a.ghost { border: 1px solid var(--border-strong); }
/* The section links need ~810px before they stop pushing the header past the viewport.
At 640px they already did not fit: the page overflowed by 63px through the whole
641-767px band, which body{overflow-x:hidden} hid rather than fixed. */
@media (max-width: 860px) { .nav-hide { display: none; } }
.icon-btn {
display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem;
border: 1px solid var(--border-ui); border-radius: var(--radius);
background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0;
}
.icon-btn:hover { color: var(--text); }
/* ---------------------------------------------------------------- hero */
.hero { padding-block: clamp(3rem, 7vw, 6rem) clamp(2rem, 4vw, 3.5rem); }
.hero-grid { display: grid; gap: clamp(2rem, 4vw, 3.5rem); align-items: start; }
@media (min-width: 900px) { .hero-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); } }
.hero h1 { margin-bottom: 1.1rem; }
.hero h1 .accent { color: var(--accent-text); }
.hero p.lede { font-size: 1.125rem; margin-bottom: 1.75rem; }
.cta-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.btn {
display: inline-flex; align-items: center; gap: .5rem; font: 600 .9375rem/1 var(--sans);
padding: .75rem 1.15rem; border-radius: var(--radius); text-decoration: none; cursor: pointer; border: 1px solid transparent;
}
.btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
/* Re-assert label colours on hover: the base a:hover (0,1,1) outranks these classes
(0,1,0), and in dark theme --accent-text equals --accent-solid — a hovered label
painted in its own background. */
.btn-primary:hover { filter: brightness(1.08); color: var(--on-accent); }
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); }
.btn-secondary:hover { border-color: var(--text-faint); color: var(--text); }
.fact-row {
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
font-size: .8125rem; color: var(--text-muted);
}
.fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; }
.fact b { color: var(--text); font-weight: 600; }
/* ---------------------------------------------------------------- code */
.code {
position: relative; background: var(--code-bg); border: 1px solid var(--code-border);
border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow);
}
.code-head {
display: flex; align-items: center; gap: .5rem; padding: .55rem .9rem;
border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised);
font: 500 .75rem/1 var(--mono); color: var(--code-faint);
}
/* Use 3 of 3, and a deletion: the fake traffic lights are gone. */
.code-head::before {
content: ""; flex: none; width: 1.05rem; height: 2px;
background-image: var(--rule-code);
}
.code-head .right { margin-left: auto; }
.code pre {
margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto;
color: var(--code-text); font-size: .84375rem; line-height: 1.75; tab-size: 2;
}
.code pre code { display: block; min-width: max-content; }
.ln { display: block; padding-inline: .35rem; margin-inline: -.35rem; border-radius: 3px; }
.t-comment { color: var(--t-comment); font-style: italic; }
.t-string { color: var(--t-string); }
.t-keyword { color: var(--t-keyword); }
.t-decorator { color: var(--t-decorator); }
.t-type { color: var(--t-type); }
.t-number { color: var(--t-number); }
/* The page's one visual device: the line the compiler refuses. */
.ln--error {
background: color-mix(in srgb, var(--err-line) 9%, transparent);
text-decoration: underline wavy var(--err-line);
text-decoration-skip-ink: none;
text-underline-offset: .32em;
}
.tsc-error {
display: flex; gap: .6rem; align-items: flex-start;
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
background: color-mix(in srgb, var(--err-line) 8%, transparent); color: var(--err-text);
font: 500 .78125rem/1.5 var(--mono);
}
.tsc-error .mark { color: var(--err-line); flex-shrink: 0; }
/* --------------------------------------------------------------- panels */
.panel {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.35rem;
}
.grid { display: grid; gap: 1.1rem; }
@media (min-width: 720px) { .grid-2 { grid-template-columns: 1fr 1fr; } }
@media (min-width: 900px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }
.panel h3 { margin-bottom: .5rem; display: flex; align-items: center; gap: .5rem; }
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
.tick { color: var(--ok); font-weight: 700; }
.cross { color: var(--bad); font-weight: 700; }
.warn-mark { color: var(--warn); font-weight: 700; }
.compare { display: grid; gap: 1rem; }
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
.compare-label {
display: flex; align-items: center; gap: .5rem; margin: 0 0 .6rem;
font: 600 .8125rem/1 var(--sans); color: var(--text-muted);
}
.pill { font: 600 .6875rem/1 var(--sans); padding: .28rem .5rem; border-radius: 999px; }
.pill-bad { background: var(--bad-soft); color: var(--bad); }
.pill-ok { background: var(--ok-soft); color: var(--ok); }
/* ----------------------------------------------------------- playground */
.pg { background: var(--bg-sunken); border-block: 1px solid var(--border); }
.pg-bar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: 1rem; }
.tabs { display: flex; flex-wrap: wrap; gap: .35rem; }
.tab {
font: 500 .8125rem/1 var(--sans); padding: .45rem .7rem; border-radius: 999px; cursor: pointer;
background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border);
}
.tab:hover { color: var(--text); }
.tab[aria-pressed="true"] { background: var(--accent-solid); border-color: var(--accent-solid); color: var(--on-accent); }
.pg-grid { display: grid; gap: 1rem; }
@media (min-width: 960px) { .pg-grid { grid-template-columns: 1fr 1fr; } }
.pg-pane { display: flex; flex-direction: column; min-width: 0; }
#editor {
flex: 1; width: 100%; min-height: 400px; resize: vertical;
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre;
overflow: auto;
}
#editor:focus { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
/* Scrollable code samples are keyboard-focusable in Chromium; the page-level ring is
unreadable on the dark slab and its +2px offset would be clipped by .code overflow. */
.code :focus-visible, #output:focus-visible { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
#output {
flex: 1; min-height: 400px; margin: 0; overflow: auto;
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); white-space: pre-wrap; word-break: break-word;
}
#output .out-err { color: var(--err-text); }
#output .out-dim { color: var(--code-faint); }
.pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; }
/* ---------------------------------------------------------------- table */
.table-scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-raised); }
table { border-collapse: collapse; width: 100%; font-size: .9375rem; }
th, td { text-align: left; padding: .7rem .95rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
thead th { font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); font-weight: 700; }
tbody tr:last-child td { border-bottom: none; }
td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
/* ------------------------------------------------------------ reference */
.ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; }
#ref-filter {
flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem;
border: 1px solid var(--border-ui); border-radius: var(--radius);
background: var(--bg-raised); color: var(--text);
}
#ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.ref-groups { display: grid; gap: 1.1rem; }
@media (min-width: 780px) { .ref-groups { grid-template-columns: 1fr 1fr; } }
@media (min-width: 1080px) { .ref-groups { grid-template-columns: repeat(3, 1fr); } }
.ref-group { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.1rem 1.2rem; }
.ref-group h3 { font-size: .9375rem; margin-bottom: .3rem; }
.ref-group .blurb { font-size: .8125rem; color: var(--text-faint); margin: 0 0 .8rem; }
.ref-group ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
.ref-group li { font-size: .8125rem; color: var(--text-muted); }
.ref-group li code { color: var(--text); background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 5px; padding: .1rem .3rem; font-size: .78125rem; }
.ref-group li .sum { display: block; margin-top: .15rem; }
.ref-empty { color: var(--text-faint); font-size: .9375rem; }
/* --------------------------------------------------------------- prose */
.notes { display: grid; gap: .9rem; }
/* Use 2 of 3: the same rhythm stood on end. */
.note-item { padding-left: 1rem; background: var(--rule-y) left top / 2px 100% no-repeat; }
.note-item h3 { font-size: .9375rem; margin-bottom: .25rem; }
.note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
.callout {
border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent);
background: var(--warn-soft); border-radius: var(--radius-lg); padding: 1rem 1.15rem;
font-size: .9375rem; color: var(--text);
}
.callout strong { color: var(--warn); }
.inline-code { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 5px; padding: .08rem .3rem; font-size: .875em; }
footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(--text-muted); font-size: .875rem; }
.foot-grid { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between; align-items: center; }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="nav">
<div class="wrap nav-inner">
<a class="brand" href="#top"><svg class="mark" width="10" height="18" viewBox="0 0 10 18" aria-hidden="true" focusable="false"><rect x="4" y="0" width="2" height="18" fill="currentColor" opacity=".3"/><rect x="0" y="2" width="10" height="2" fill="currentColor"/><rect x="0" y="6" width="10" height="2" fill="currentColor"/><rect x="0" y="10" width="10" height="2" fill="currentColor"/><rect x="0" y="14" width="10" height="2" fill="currentColor"/></svg>cereale <span class="badge" id="version-badge">v0.4.0</span></a>
<nav class="nav-links" aria-label="Primary">
<a class="nav-hide" href="#guarantee">Guarantee</a>
<a class="nav-hide" href="#playground">Playground</a>
<a class="nav-hide" href="#errors">Errors</a>
<a class="nav-hide" href="#install">Install</a>
<a class="nav-hide" href="#size">Size</a>
<a class="nav-hide" href="#reference">Reference</a>
<a class="ghost" href="https://github.com/avalon-vanguard/cereale">GitHub</a>
<button class="icon-btn" id="theme-toggle" type="button" aria-label="Switch between light and dark theme">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
</svg>
</button>
</nav>
</div>
</header>
<main id="main">
<!-- ============================================================= hero -->
<section class="hero" id="top">
<div class="wrap hero-grid">
<div>
<h1>Validated <span class="accent">domain objects</span>, not validated data.</h1>
<p class="lede">
cereale maps JSON onto your own classes and gives you back real instances — with your
methods, your inheritance, your <code class="inline-code">instanceof</code> checks. And every
rule that implies a type is checked against the field it is attached to, at compile time.
</p>
<div class="cta-row">
<a class="btn btn-primary" href="#playground">
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6V4z"/></svg>
Run it in the browser
</a>
<a class="btn btn-secondary" href="https://github.com/avalon-vanguard/cereale">Read the source</a>
</div>
<div class="fact-row">
<span class="fact"><b>0</b> runtime dependencies</span>
<span class="fact"><b id="decorator-count">68</b> decorators</span>
<span class="fact">TC39 <b>standard decorators</b></span>
<span class="fact">ESM + CJS + single-file</span>
<span class="fact">Node <b id="node-req">&ge;20</b></span>
<span class="fact"><b>1.8 KB</b> for one decorator</span>
</div>
</div>
<div>
<div class="code">
<div class="code-head">
<span class="name">order.ts</span>
</div>
<pre><code data-lang="ts" data-error-line="9">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</code></pre>
<!-- Replaced at load with the verbatim diagnostic that scripts/build-docs.mjs
captured from tsc. The static text is the fallback, and the build fails if
this snippet ever stops being a compile error. -->
<p class="tsc-error" id="hero-diagnostic" data-case="hero"><span class="mark" aria-hidden="true">✖</span><span><strong>ts(1240)</strong> Unable to resolve signature of property decorator when called as an expression.<br>&nbsp;&nbsp;…&nbsp;&nbsp;Type 'Date' is not assignable to type 'string'.</span></p>
</div>
<p class="pg-note">
That is the compiler, not cereale — and it is the real thing: the snippet above is
compiled by <code class="inline-code">tsc</code> when this page is built, and the build
fails if it ever stops being an error.
</p>
</div>
</div>
</section>
<!-- ======================================================== guarantee -->
<section id="guarantee">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">The guarantee</p>
<h2>Your rules and your types cannot disagree</h2>
<p class="lede">
A legacy decorator is typed <code class="inline-code">(prototype, propertyName) =&gt; void</code>.
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
<code class="inline-code">ClassFieldDecoratorContext&lt;This, Value&gt;</code>, which does
carry it. cereale uses it.
</p>
</div>
<div class="compare">
<div>
<p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p>
<div class="code">
<div class="code-head"><span class="name">with legacy decorators</span></div>
<pre><code data-lang="ts">class User {
@IsString()
age: number; // accepted by the compiler
}
// ...discovered in production, on a payload
// that happened to carry the wrong shape.</code></pre>
</div>
</div>
<div>
<p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p>
<div class="code">
<div class="code-head"><span class="name">with standard decorators</span></div>
<pre><code data-lang="ts" data-error-line="2">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.</code></pre>
</div>
</div>
</div>
<div class="grid grid-3" style="margin-top:1.75rem">
<div class="panel">
<h3>Scalars against scalars</h3>
<p><code class="inline-code">@Min(1)</code> on a <code class="inline-code">string</code> is a compile error, as is <code class="inline-code">@MinLength(2)</code> on a <code class="inline-code">number</code>.</p>
</div>
<div class="panel">
<h3><code class="inline-code">each</code> against arrays</h3>
<p><code class="inline-code">@IsString({ each: true })</code> demands a <code class="inline-code">string[]</code>; a bare <code class="inline-code">@IsString()</code> on one is rejected.</p>
</div>
<div class="panel">
<h3>Classes against classes</h3>
<p><code class="inline-code">@JsonType(() =&gt; Address)</code>, <code class="inline-code">@JsonSerialize</code> and <code class="inline-code">@IsEnum(E)</code> are all checked against the field.</p>
</div>
</div>
</div>
</section>
<!-- ========================================================= instances -->
<section id="instances" style="background:var(--bg-sunken);border-block:1px solid var(--border)">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">What you get back</p>
<h2>An instance of your class, not a shape that resembles it</h2>
<p class="lede">
Nested objects and arrays you declare with <code class="inline-code">@JsonType</code>, and
subtypes you declare with <code class="inline-code">@JsonPolymorphic</code>, 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.
</p>
</div>
<div class="compare">
<div class="code">
<div class="code-head"><span class="name">catalogue.ts</span></div>
<pre><code data-lang="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&lt;Media&gt;('type', [
{ value: Movie, name: 'movie' },
{ value: Song, name: 'song' },
])
@ValidateNested({ each: true })
items!: Media[];
}</code></pre>
</div>
<div class="code">
<div class="code-head"><span class="name">what comes out</span></div>
<pre><code data-lang="ts">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.</code></pre>
</div>
</div>
</div>
</section>
<!-- ======================================================== playground -->
<section class="pg" id="playground">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Playground</p>
<h2>The real library, running here</h2>
<p class="lede">
This page bundles cereale itself and compiles what you type with standard decorators.
Edit anything and run it.
</p>
</div>
<div class="pg-bar">
<!-- Deliberately not role="tablist": these load code into a single editor rather than
switching panels, and a half-implemented tab widget (no tabpanel, no arrow-key
navigation) misleads a screen reader more than plain toggle buttons do. -->
<div class="tabs" role="group" aria-label="Playground examples" id="tabs"></div>
<button class="btn btn-primary" id="run-btn" type="button" style="margin-left:auto">
<svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6V4z"/></svg>
Run
</button>
</div>
<div class="pg-grid">
<div class="pg-pane">
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
<span class="name">playground.ts</span>
</div>
<textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
aria-label="TypeScript source to run"></textarea>
</div>
<div class="pg-pane">
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
<span class="name">output</span>
<span class="right" id="pg-status"></span>
</div>
<pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre>
</div>
</div>
<p class="pg-note">
The compiler here strips types and lowers decorators; it does not type-check. The compile-time
guarantee above is what your editor and <code class="inline-code">tsc</code> give you — a browser
cannot demonstrate an error that stops a build.
</p>
</div>
</section>
<!-- ============================================================ errors -->
<section id="errors">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Diagnosability</p>
<h2>Nothing fails quietly</h2>
<p class="lede">
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.
</p>
</div>
<div class="grid grid-3">
<div class="panel">
<h3>A <code class="inline-code">Map</code> used to become <code class="inline-code">{}</code></h3>
<p>Along with <code class="inline-code">Set</code>, <code class="inline-code">RegExp</code>, <code class="inline-code">Error</code>, typed arrays, <code class="inline-code">bigint</code>, symbols and functions — each with its own version of the same silence.</p>
</div>
<div class="panel">
<h3>A misconfigured compiler used to throw from inside</h3>
<p><code class="inline-code">TypeError: Cannot convert undefined or null to object</code>, which names neither the cause nor the one-line fix.</p>
</div>
<div class="panel">
<h3>A build used to succeed while emitting nothing that runs</h3>
<p>Vite 8 passes decorator syntax straight through. The bundle builds; the first import throws.</p>
</div>
</div>
<div style="margin-top:1.5rem" class="code">
<div class="code-head"><span class="name">what you get instead</span></div>
<pre><code data-lang="text">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.</code></pre>
</div>
</div>
</section>
<!-- ========================================================= toolchain -->
<section id="toolchain" style="background:var(--bg-sunken);border-block:1px solid var(--border)">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Before you install</p>
<h2>The toolchain cost, stated plainly</h2>
<p class="lede">
cereale reads the metadata that only a <strong>standard</strong> 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.
</p>
</div>
<div class="table-scroll">
<table>
<caption class="sr-only">Transformer support for TC39 standard decorators</caption>
<thead>
<tr><th scope="col">Transformer</th><th scope="col">Works</th><th scope="col">Setting</th></tr>
</thead>
<tbody>
<tr><td><code>tsc</code>&nbsp;5.2+</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code>, <code>target: ES2022</code>+</td></tr>
<tr><td>esbuild</td><td><span class="tick">✓</span></td><td class="note"><code>experimentalDecorators: false</code> via <code>tsconfigRaw</code>, <em>plus</em> esbuild's own top-level <code>target: es2022</code> — its default <code>esnext</code> leaves the decorators in place</td></tr>
<tr><td>swc</td><td><span class="tick">✓</span></td><td class="note"><code>jsc.transform.decoratorVersion: "2022-03"</code></td></tr>
<tr><td>oxc</td><td><span class="cross">✗</span></td><td class="note">used by <strong>Vite&nbsp;8</strong> and <strong>Vitest&nbsp;4</strong> — see below</td></tr>
</tbody>
</table>
</div>
<div class="callout" style="margin-top:1.25rem">
<strong>On Vite 8 or Vitest 4?</strong> oxc leaves decorator syntax in the output and reports
nothing: <code class="inline-code">vitest</code> prints <em>0 test</em> next to a bare
<code class="inline-code">SyntaxError</code>, and <code class="inline-code">vite build</code>
reports success while emitting a bundle that throws on first import. cereale ships the plugin
that fixes it.
</div>
<div class="table-scroll" style="margin-top:1.5rem">
<table>
<caption class="sr-only">Framework support</caption>
<thead><tr><th scope="col">Framework</th><th scope="col">Works</th><th scope="col">What it takes</th></tr></thead>
<tbody>
<tr><td>Angular&nbsp;21</td><td><span class="tick">✓</span></td><td class="note">Flip the scaffolded <code>experimentalDecorators</code> to <code>false</code> — Angular does not need it</td></tr>
<tr><td>React, Vue, Svelte&hellip;</td><td><span class="tick">✓</span></td><td class="note">Any Vite&nbsp;8 app — add the plugin below</td></tr>
<tr><td>Bun&nbsp;1.3</td><td><span class="tick">✓</span></td><td class="note">Nothing</td></tr>
<tr><td>Node + <code>tsc</code></td><td><span class="tick">✓</span></td><td class="note">Just the flag</td></tr>
<tr><td>Next.js&nbsp;16</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — keep models in a package compiled by <code>tsc</code></td></tr>
<tr><td>NestJS&nbsp;11.1</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — its DI needs <code>emitDecoratorMetadata</code>; same precompiled route</td></tr>
</tbody>
</table>
</div>
<p class="pg-note" style="max-width:var(--measure)">
Each of these was set up and run before it was written down, on 2026-08-05, against the
versions listed.
<a href="https://github.com/avalon-vanguard/cereale/blob/main/FRAMEWORKS.md">FRAMEWORKS.md</a>
has the full recipe for every one, including the two that need the precompiled route.
</p>
<div class="code" style="margin-top:1.25rem;max-width:640px">
<div class="code-head"><span class="name">vite.config.ts</span></div>
<pre><code data-lang="ts">import { defineConfig } from 'vite';
import { standardDecorators } from 'cereale/vite';
export default defineConfig({
plugins: [standardDecorators()],
});</code></pre>
</div>
<p class="pg-note" style="max-width:var(--measure)">
It transforms <code class="inline-code">.ts</code>, <code class="inline-code">.mts</code> and
<code class="inline-code">.cts</code> outside <code class="inline-code">node_modules</code> with
esbuild, falling back to the TypeScript compiler — cereale depends on neither. Decorated
classes in <code class="inline-code">.tsx</code> need an <code class="inline-code">include</code>
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.
</p>
</div>
</section>
<!-- =========================================================== install -->
<section id="install">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Getting started</p>
<h2>Two settings, one caveat, and two ways in</h2>
</div>
<div class="compare">
<div>
<p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p>
<div class="code">
<div class="code-head"><span class="name">tsconfig.json</span></div>
<pre><code data-lang="text">{
"compilerOptions": {
"target": "ES2022",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false
}
}</code></pre>
</div>
<p class="pg-note">
No <code class="inline-code">reflect-metadata</code>, and
<code class="inline-code">emitDecoratorMetadata</code> is never read. If
<code class="inline-code">experimentalDecorators</code> is on, cereale says so by name
instead of failing somewhere inside itself.
</p>
</div>
<div>
<p class="compare-label"><span class="pill pill-bad">not on npm yet</span> installing it today</p>
<div class="code">
<div class="code-head"><span class="name">shell</span></div>
<pre><code data-lang="text" id="install-shell">git clone https://github.com/avalon-vanguard/cereale
cd cereale
npm install &amp;&amp; npm run build
npm pack # → cereale-0.4.0.tgz
# then, from your own project
npm install ../cereale/cereale-0.4.0.tgz</code></pre>
</div>
<p class="pg-note">
<code class="inline-code">npm install cereale</code> does <strong>not</strong> resolve to
this library — the name is unclaimed on the registry. Installing straight from GitHub
will not work either: the build output is not committed, so the package would arrive
without its <code class="inline-code">dist/</code>.
</p>
</div>
</div>
<div class="panel" style="margin-top:1.75rem">
<h3>No bundler at all</h3>
<p>
<code class="inline-code">cereale/min</code> is the whole library flattened into one
minified ES module — <strong>33.9&nbsp;KB</strong>, 9.6&nbsp;KB gzipped — for import maps,
a bare <code class="inline-code">&lt;script type="module"&gt;</code>, Deno and Workers.
</p>
<div class="code">
<div class="code-head"><span class="name">index.html</span></div>
<pre><code data-lang="text">&lt;script type="importmap"&gt;
{ "imports": { "cereale": "./node_modules/cereale/dist/cereale.min.js" } }
&lt;/script&gt;
&lt;script type="module"&gt;
import { IsString, toInstanceSync } from 'cereale';
&lt;/script&gt;</code></pre>
</div>
<p class="pg-note">
<strong>If you are using a bundler, prefer the default entry.</strong> 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.
</p>
</div>
</div>
</section>
<!-- ========================================================= size -->
<section id="size" style="background:var(--bg-sunken);border-block:1px solid var(--border)">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Bundle size</p>
<h2>You pay for the decorators you name</h2>
<p class="lede">
<b class="js-dec-count">68</b> 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.
</p>
</div>
<div class="table-scroll">
<table>
<caption class="sr-only">Minified bytes by import, across three bundlers</caption>
<thead>
<tr>
<th scope="col">What you import</th>
<th scope="col">esbuild</th><th scope="col">rollup</th><th scope="col">webpack</th>
</tr>
</thead>
<tbody>
<tr><td><code>flattenErrors</code></td><td>394</td><td>367</td><td>394</td></tr>
<tr><td>one decorator</td><td>1,837</td><td>1,823</td><td>1,818</td></tr>
<tr><td><code>validateSync</code></td><td>3,722</td><td>3,554</td><td>3,738</td></tr>
<tr><td><code>toPlainSync</code></td><td>7,744</td><td>7,769</td><td>7,771</td></tr>
<tr><td><code>toInstanceSync</code></td><td>7,900</td><td>7,942</td><td>7,944</td></tr>
<tr><td>a typical DTO</td><td>10,395</td><td>10,402</td><td>10,360</td></tr>
<tr><td>the whole library</td><td>26,266</td><td>25,671</td><td>26,879</td></tr>
</tbody>
</table>
</div>
<div class="notes" style="margin-top:1.5rem">
<div class="note-item">
<h3>Reading and writing drop independently</h3>
<p>
Import <code class="inline-code">toInstanceSync</code> and you do not pay for the
serializer. The validator stays in both, because
<code class="inline-code">validate</code> defaults to
<code class="inline-code">true</code> — a real reference, not a missed optimisation.
</p>
</div>
<div class="note-item">
<h3>It did not come for free</h3>
<p>
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
<code class="inline-code">/*#__PURE__*/</code> on each, one decorator cost
<strong>4,909 bytes instead of 1,837</strong>. Nothing failed — the library was just
three times heavier in every bundle, and the only way to find out was to measure.
</p>
</div>
<div class="note-item">
<h3>rollup alone would have shown nothing</h3>
<p>
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 href="https://github.com/avalon-vanguard/cereale/blob/main/src/treeshake.test.ts">a test</a>
now pins the property rather than the prose.
</p>
</div>
</div>
</div>
</section>
<!-- ========================================================= reference -->
<section id="reference">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Reference</p>
<h2>Everything cereale exports</h2>
<p class="lede">
Every rule that implies a type is checked against the field it decorates — a few
deliberately do not, because they fit any field
(<code class="inline-code">@IsDefined</code>) or because the constraint is negative and
narrowing it would be backwards (<code class="inline-code">@IsNotIn</code>). All of them take
<code class="inline-code">{ each: true }</code> to run per element of an array, and a
<code class="inline-code">message</code>, reported verbatim.
</p>
</div>
<div class="ref-bar">
<label for="ref-filter" class="sr-only">Filter the reference</label>
<input id="ref-filter" type="search" placeholder="Filter — try “uuid”, “array”, “naming”…" autocomplete="off">
<span id="ref-count"></span>
</div>
<div class="ref-groups" id="ref-groups"></div>
<noscript>
<p class="ref-empty">The reference is rendered with JavaScript. The full list is in the
<a href="https://github.com/avalon-vanguard/cereale#api-reference">README</a>.</p>
</noscript>
</div>
</section>
<!-- ============================================================ limits -->
<section id="limits" style="background:var(--bg-sunken);border-top:1px solid var(--border)">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">Honest limits</p>
<h2>What cereale is not</h2>
</div>
<div class="grid grid-2">
<div class="notes">
<div class="note-item">
<h3>It does not infer types from a schema</h3>
<p>You write the field type <em>and</em> 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.</p>
</div>
<div class="note-item">
<h3>It cannot coexist with legacy decorators</h3>
<p>The two decorator systems are a whole-program setting. A project that still needs
<code class="inline-code">experimentalDecorators</code> for another library cannot use cereale yet.</p>
</div>
<div class="note-item">
<h3>Rules live on the class, not on the data</h3>
<p><code class="inline-code">validate()</code> on a plain object reports nothing. Validate the
instance you get back from <code class="inline-code">toInstance</code>.</p>
</div>
</div>
<div class="notes">
<div class="note-item">
<h3>Renaming is not backwards-compatible by itself</h3>
<p>Once a property carries <code class="inline-code">@JsonProperty</code>, its original name
no longer reaches it — it is refused rather than copied onto the instance behind the
rename's back. Add <code class="inline-code">@JsonAlias</code> to keep older clients
working. Under <code class="inline-code">unknownKeys: 'error'</code> the stale name is
reported by name, along with what the property is called now.</p>
</div>
<div class="note-item">
<h3><code class="inline-code">abstract</code> and <code class="inline-code">accessor</code> fields cannot be decorated</h3>
<p>Standard decorators do not apply to abstract members, and an
<code class="inline-code">accessor</code> field keeps its value in a private slot that mapping
cannot reach. Both are errors rather than silent no-ops.</p>
</div>
<div class="note-item">
<h3>It is not on npm yet</h3>
<p><span class="js-version">0.4.0</span> lives in the repository. <code class="inline-code">npm install cereale</code> does not
resolve to this library — build it from source until it is published.</p>
</div>
</div>
</div>
</div>
</section>
</main>
<footer>
<div class="wrap foot-grid">
<p style="margin:0">cereale — MIT licensed. Built by Avalon Vanguard.</p>
<p style="margin:0">
<a href="https://github.com/avalon-vanguard/cereale">Source</a> ·
<a href="https://github.com/avalon-vanguard/cereale/blob/main/CHANGELOG.md">Changelog</a> ·
<a href="https://github.com/avalon-vanguard/cereale/issues">Issues</a>
</p>
</div>
</footer>
<script src="meta.js"></script>
<script src="diagnostics.js"></script>
<script src="cereale.js"></script>
<script src="page.js"></script>
</body>
</html>