Of 26 findings raised across five auditors, 23 were refuted on a second
pass. These three survived.
**A renamed property's old key is still writable.** The README, the landing
page and two doc comments all said that once a property carries
@JsonProperty its original name "is no longer accepted on input". It is no
longer *mapped* — but it is not rejected either. Unlike @JsonReadOnly,
whose JSON name goes into the blocked set, the old key falls through to the
unknown-key policy, and the default `allow` copies it onto the instance
untouched. Reproduced against dist:
@JsonProperty('home_address') @JsonType(() => Addr) @ValidateNested()
address!: Addr;
toInstanceSync(Order, { address: { city: 'Paris' } })
-> address is a plain object, instanceof Addr === false
-> validateSync() returns [] <- nothing complains
-> round-trips out as home_address <- silently accepted
Blocking the old key would fix it, but would also swallow the
`unknownKeys: 'error'` report a strict caller gets today, which is arguably
the more useful signal. That is a judgement call the library has not made,
so this commit states the behaviour accurately everywhere it was stated
wrongly and pins it with five tests covering the default, `strip`, `error`
and the @JsonAlias fix — so it cannot drift either way while the question
is open.
**@IsNotEmpty and @IsEmpty are not complements.** `[]` and `{}` pass BOTH:
isNotEmpty checks only null/undefined/'' while isEmpty also treats empty
arrays and objects as empty. Listed one line apart as "must not be empty" /
"must be empty", they invited exactly the wrong inference.
**unknownKeys is deserialization-only**, in a group whose blurb says these
apply per call or via configure().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
859 lines
40 KiB
HTML
859 lines
40 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="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌾</text></svg>">
|
|
|
|
<style>
|
|
/* ---------------------------------------------------------------- tokens */
|
|
:root {
|
|
--bg: #fbfbfd;
|
|
--bg-raised: #ffffff;
|
|
--bg-sunken: #f3f3f7;
|
|
--text: #16161d;
|
|
--text-muted: #55555f;
|
|
--text-faint: #6b6b78;
|
|
--border: #e3e3ea;
|
|
--border-strong: #cfcfd9;
|
|
--accent: #4f46e5;
|
|
--accent-text: #4338ca;
|
|
--accent-soft: #eef2ff;
|
|
/* 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: #4f46e5;
|
|
--on-accent: #ffffff;
|
|
--bad: #d4183d;
|
|
--bad-soft: #fff1f3;
|
|
--ok: #08795a;
|
|
--ok-soft: #eefaf5;
|
|
--warn: #9a5b00;
|
|
--warn-soft: #fff7ea;
|
|
|
|
/* Code surfaces stay dark in both themes: one syntax palette, always legible. */
|
|
--code-bg: #16161f;
|
|
--code-bg-raised: #1e1e29;
|
|
--code-border: #2b2b3a;
|
|
--code-text: #d6deeb;
|
|
--code-faint: #8a93b8;
|
|
--t-comment: #8a93b8;
|
|
--t-string: #b8e08a;
|
|
--t-keyword: #c792ea;
|
|
--t-decorator: #82aaff;
|
|
--t-type: #ffcb6b;
|
|
--t-number: #f78c6c;
|
|
|
|
--radius: 10px;
|
|
--radius-lg: 16px;
|
|
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18);
|
|
--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: 68ch;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #0e0e14;
|
|
--bg-raised: #16161f;
|
|
--bg-sunken: #12121a;
|
|
--text: #e8e8f0;
|
|
--text-muted: #a3a3b4;
|
|
--text-faint: #9494ab;
|
|
--border: #262633;
|
|
--border-strong: #363648;
|
|
--accent: #8b85ff;
|
|
--accent-text: #a5a0ff;
|
|
--accent-soft: #1c1b35;
|
|
--accent-solid: #8b85ff;
|
|
--on-accent: #10101a;
|
|
--bad: #ff8095;
|
|
--bad-soft: #2a1620;
|
|
--ok: #5cd0a8;
|
|
--ok-soft: #10241f;
|
|
--warn: #e5a54b;
|
|
--warn-soft: #251d10;
|
|
--code-bg: #12121a;
|
|
--code-bg-raised: #191922;
|
|
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
|
|
}
|
|
}
|
|
|
|
/* The toggle wins over the media query in both directions. */
|
|
:root[data-theme="light"] {
|
|
--bg: #fbfbfd; --bg-raised: #ffffff; --bg-sunken: #f3f3f7;
|
|
--text: #16161d; --text-muted: #55555f; --text-faint: #6b6b78;
|
|
--border: #e3e3ea; --border-strong: #cfcfd9;
|
|
--accent: #4f46e5; --accent-text: #4338ca; --accent-soft: #eef2ff;
|
|
--accent-solid: #4f46e5; --on-accent: #ffffff;
|
|
--bad: #d4183d; --bad-soft: #fff1f3; --ok: #08795a; --ok-soft: #eefaf5;
|
|
--warn: #9a5b00; --warn-soft: #fff7ea;
|
|
--code-bg: #16161f; --code-bg-raised: #1e1e29;
|
|
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18);
|
|
color-scheme: light;
|
|
}
|
|
:root[data-theme="dark"] {
|
|
--bg: #0e0e14; --bg-raised: #16161f; --bg-sunken: #12121a;
|
|
--text: #e8e8f0; --text-muted: #a3a3b4; --text-faint: #9494ab;
|
|
--border: #262633; --border-strong: #363648;
|
|
--accent: #8b85ff; --accent-text: #a5a0ff; --accent-soft: #1c1b35;
|
|
--accent-solid: #8b85ff; --on-accent: #10101a;
|
|
--bad: #ff8095; --bad-soft: #2a1620; --ok: #5cd0a8; --ok-soft: #10241f;
|
|
--warn: #e5a54b; --warn-soft: #251d10;
|
|
--code-bg: #12121a; --code-bg-raised: #191922;
|
|
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 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.6;
|
|
-webkit-font-smoothing: antialiased;
|
|
overflow-x: hidden;
|
|
}
|
|
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 680; letter-spacing: -.02em; }
|
|
h1 { font-size: clamp(2.1rem, 1.3rem + 3.4vw, 3.5rem); letter-spacing: -.035em; }
|
|
h2 { font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.1rem); letter-spacing: -.028em; }
|
|
h3 { font-size: 1.125rem; }
|
|
p { margin: 0 0 1rem; }
|
|
a { color: var(--accent-text); text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); text-underline-offset: .18em; }
|
|
a:hover { 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-size: .75rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
|
|
color: var(--accent-text); margin: 0 0 .6rem;
|
|
}
|
|
.section-head { margin-bottom: 2.25rem; }
|
|
.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; }
|
|
.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: baseline; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; }
|
|
.brand .grain { font-size: 1rem; }
|
|
.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); }
|
|
@media (max-width: 640px) { .nav-hide { display: none; } }
|
|
.icon-btn {
|
|
display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem;
|
|
border: 1px solid var(--border-strong); 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); }
|
|
.btn-primary:hover { filter: brightness(1.08); }
|
|
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-strong); }
|
|
.btn-secondary:hover { border-color: var(--text-faint); }
|
|
.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: 640; }
|
|
|
|
/* ---------------------------------------------------------------- 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);
|
|
}
|
|
.code-head .dot { width: .5rem; height: .5rem; border-radius: 50%; background: #33334a; }
|
|
.code-head .name { margin-left: .35rem; }
|
|
.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: rgba(255, 92, 122, .09);
|
|
text-decoration: underline wavy #ff5c7a;
|
|
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: rgba(255, 92, 122, .08); color: #ffb3c0;
|
|
font: 500 .78125rem/1.5 var(--mono);
|
|
}
|
|
.tsc-error .mark { color: #ff5c7a; 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; }
|
|
|
|
.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); 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: #ff8095; }
|
|
#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-strong); 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; }
|
|
.note-item { border-left: 2px solid var(--border-strong); padding-left: 1rem; }
|
|
.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"><span class="grain" aria-hidden="true">🌾</span>cereale <span class="badge" id="version-badge">v0.3.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="#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</span>
|
|
<span class="fact">Node <b id="node-req">≥20</b></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="code">
|
|
<div class="code-head">
|
|
<span class="dot" aria-hidden="true"></span><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> … 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) => 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<This, Value></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="dot" aria-hidden="true"></span><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="dot" aria-hidden="true"></span><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(() => 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="dot" aria-hidden="true"></span><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<Media>('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="dot" aria-hidden="true"></span><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="dot" aria-hidden="true"></span><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="dot" aria-hidden="true"></span><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 exists 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="dot" aria-hidden="true"></span><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 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></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 8</strong> and <strong>Vitest 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="code" style="margin-top:1.25rem;max-width:640px">
|
|
<div class="code-head"><span class="dot" aria-hidden="true"></span><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, and one caveat</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="dot" aria-hidden="true"></span><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="dot" aria-hidden="true"></span><span class="name">shell</span></div>
|
|
<pre><code data-lang="text">git clone https://github.com/Avalon-Vanguard/cereale
|
|
cd cereale
|
|
npm install && npm run build
|
|
npm pack # → cereale-0.3.0.tgz
|
|
|
|
# then, from your own project
|
|
npm install ../cereale/cereale-0.3.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>
|
|
</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 <em>maps</em> to it — but under the default
|
|
<code class="inline-code">unknownKeys: 'allow'</code> it is not rejected either. It is
|
|
copied onto the instance raw, skipping any
|
|
<code class="inline-code">@JsonType</code> or <code class="inline-code">@JsonDeserialize</code>
|
|
conversion declared for that field. Add <code class="inline-code">@JsonAlias</code> to keep
|
|
older clients working, or <code class="inline-code">unknownKeys: 'strip'</code> to drop them.</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>0.3.0 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>
|