Merge pull request #11 from avalon-vanguard/develop

🌳 Release 0.4.0 — tree-shaking, and `cereale/min`
This commit is contained in:
Senrokai
2026-08-07 14:12:05 +02:00
committed by GitHub
14 changed files with 926 additions and 45 deletions
+1
View File
@@ -39,6 +39,7 @@ jobs:
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
node --input-type=module -e "import { standardDecorators } from './dist/esm/vite.js'; if (standardDecorators().enforce !== 'pre') throw new Error('ESM cereale/vite broken');"
node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');"
node --input-type=module -e "import * as m from './dist/cereale.min.js'; if (typeof m.toInstanceSync !== 'function') throw new Error('flat bundle broken');"
- name: Run Demo
run: npm run demo
- name: Published types stand alone
+48
View File
@@ -0,0 +1,48 @@
name: Junie Review
# Automated PR review by Junie, JetBrains' coding agent. Advisory only: it posts a
# summary and inline comments, and is deliberately not a required check — CI is what
# gates a merge, and a review that can block one on a judgement call is a review that
# gets rubber-stamped.
#
# Requires a JUNIE_API_KEY repository secret (Settings → Secrets and variables →
# Actions). Without it the action fails at the first step rather than skipping, so
# add the secret before merging this file.
on:
pull_request:
types: [ opened, synchronize, reopened ]
branches: [ main, develop ]
# A PR that gets three pushes in a minute should end up with one review of the final
# state, not three reviews of intermediate ones. Combined with use_single_comment
# below, each PR keeps exactly one review comment, rewritten as the diff changes.
concurrency:
group: junie-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
name: Junie
runs-on: ubuntu-latest
# Secrets are not exposed to `pull_request` runs originating from a fork, so a
# fork PR would fail on an empty API key rather than review anything. Skip those
# explicitly — a skipped job reads as "not applicable", a failed one as "broken".
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read # read the diff; Junie does not push from this workflow
pull-requests: write # post the review summary and inline comments
issues: write # the PR conversation is an issue timeline to the API
steps:
- uses: actions/checkout@v4
- name: Review the pull request
uses: JetBrains/junie-github-action@v1
with:
junie_api_key: ${{ secrets.JUNIE_API_KEY }}
# Built-in structured review prompt, as opposed to a free-form instruction.
prompt: "code-review"
# Update one comment across re-runs instead of appending a new one per push.
use_single_comment: "true"
+113
View File
@@ -5,6 +5,119 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2026-08-05
### `cereale/min` — one file, no bundler
The whole library flattened into a single minified ES module: **33.9 KB, 9.6 KB gzipped**, for
import maps, `<script type="module">`, Deno and Workers. It is built from `dist/esm/index.js`,
so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only
flattens and minifies.
It is an addition, not a replacement. The per-module build stays the default `import`: it keeps
readable stack traces for anyone not loading source maps, and it is what a bundler should be
given.
The flat file is minified for syntax and identifiers but **not** whitespace. Full minification
strips comments — including the `/*#__PURE__*/` annotations below — which silently made
`cereale/min` un-tree-shakable: one decorator came out at 5,066 bytes against 1,837 from the
per-module entry, with all 26 unrelated rule messages back in the output. Keeping the
annotations costs about a kilobyte gzipped and is asserted by the build.
### Tree-shaking
Importing one decorator pulled in the message and validator of all 68. **4,909 bytes instead of
1,837** through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing
warned; the library was simply about three times heavier than it needed to be in every
consumer's bundle.
The cause is that every rule is a top-level call — `export const IsString = rule(…)`. rollup
proves such a call side-effect-free by reading the factory, which is why rollup was already
producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and
webpack will not do that analysis, and keep the call. Thirty declarations now carry
`/*#__PURE__*/`. On the single-decorator import that exposed the problem the three bundlers now
land within 19 bytes of each other; on larger imports they still differ by up to a few hundred,
which is ordinary bundler variation rather than anything left unshaken.
Measured, minified, across esbuild / rollup / webpack:
| What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: |
| `flattenErrors` | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,738 |
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
| a typical DTO | 10,395 | 10,402 | 10,360 |
| everything | 26,266 | 25,671 | 26,879 |
The serializer and deserializer drop independently. The validator is kept by both mapping
entry points because `validate` defaults to `true`, which is a real reference rather than a
missed optimisation.
`src/treeshake.test.ts` pins it. The assertions are mostly about content rather than bytes — it
names the rules that must not appear — and one case asserts that everything IS present when
everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip
the annotations and it fails with `"must be a latitude" should have been shaken out`.
The same pass shook out something that was supposed to stay. Cereale installs `Symbol.metadata`
when the runtime lacks it, and `sideEffects` named the module holding that install — but not the
barrel that re-exports it. A side-effect-free barrel is droppable as a whole, so all three
bundlers pruned the `export * from './metadata.js'` edge before metadata.js's own marking was
ever consulted: `import { configure } from 'cereale'` came out at 145 bytes through esbuild, 143
through webpack and 144 through rollup, with `Symbol.metadata` in none of them. That matters
because `tsc`'s decorator emit reads the well-known symbol directly — `typeof Symbol ===
"function" && Symbol.metadata ? Object.create(null) : void 0` — so without the install a
decorated class gets `metadata: undefined`, which is to say no rules at all.
`index.js` and `index.ts` are now listed too. It costs about 100 bytes, and only on imports that
reach nothing else; every row of the table above except the first was byte-identical before and
after, across all three bundlers. Two more cases in `treeshake.test.ts` pin it, one on the source
and one on `dist/esm`, because those are separate paths in the manifest and a typo in either is
invisible from the other side.
### Frameworks
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
with the versions and date it was verified against.
The finding worth stating first: **Angular works**. The CLI scaffolds
`"experimentalDecorators": true`, but Angular does not need it — `ngtsc` erases `@Component`
and `@Injectable` into static properties rather than relying on TypeScript's decorator emit.
Flip the flag and both systems work in one program. Verified with `ngc` on Angular 21.2 with
`strictTemplates`: templates still type-check, and a wrong cereale rule is still a compile
error inside the Angular build.
**Next.js cannot work inline**, and the reason is structural rather than a missing option. It
derives *both* the SWC parser's decorator support and the transform mode from the single
`experimentalDecorators` flag, so the flag on gives legacy emit that cereale refuses, and the
flag off makes `@` a syntax error. There is no third setting.
**NestJS cannot work inline** either: its dependency injection genuinely needs the
`design:type` metadata only `emitDecoratorMetadata` produces.
Both have the same answer, and it is better than it sounds: put the cereale classes in a
package compiled by `tsc` and import the built output. The decorators run at class-definition
time inside that package, so the app only ever sees plain JavaScript and its own decorator
setting stops mattering. Verified inside a program with **both** legacy flags on, running
alongside `@Injectable()` — mapping and validation work normally, and the compile-time
guarantee still holds where the rules are written.
Also verified: **Bun** 1.3 needs no configuration at all, and a real Vite 8 build with the
`cereale/vite` plugin produces working output where the same build without it silently leaves
decorator syntax in the bundle.
### Packaging
`FRAMEWORKS.md` ships with the package. `sideEffects` now lists the flat bundle, which inlines
the `Symbol.metadata` install.
### Why 0.4.0 and not 0.3.1
`cereale/min` is a new public entry point, which is a minor bump under 0.x. It also keeps the
existing `v0.3.0` tag meaningful instead of force-moving it onto a commit it was never cut
from.
## [0.3.0] - 2026-08-05
Every change here comes from the same question: where does cereale currently fail *quietly*?
+343
View File
@@ -0,0 +1,343 @@
# Using cereale with your framework
Every recipe here was run before it was written. Where something does not work, this says so
rather than offering a workaround that has not been tried.
## The only requirement
cereale reads the metadata that a **TC39 standard** decorator transform emits. That means your
compiler must have `experimentalDecorators` **off** — which is the default in TypeScript 5.0
and later, but not what several frameworks scaffold.
There is no partial credit and no per-file override: `experimentalDecorators` is a property of
a TypeScript *program*, so every decorator in one compilation uses the same system. If the flag
is on, `tsc` refuses cereale's decorators outright:
```
error TS1240: Unable to resolve signature of property decorator when called as an expression.
Argument of type 'User' is not assignable to parameter of type 'undefined'.
```
and if you skip type-checking (esbuild, swc and Babel all strip types without checking them),
the emit reaches cereale as a legacy call and it says so by name rather than failing obscurely.
## Two ways to adopt it
**Inline** — write cereale decorators directly in your app. Needs `experimentalDecorators: false`.
This is what you want, and most toolchains allow it.
**A precompiled models package** — when the program is committed to legacy decorators for
something else (NestJS's DI, TypeORM entities, Next's SWC pipeline), put your cereale classes in
a separate package compiled by `tsc`, and import the built output. The decorators run at class
definition time inside that package; your app only ever sees plain JavaScript, so its own
decorator setting is irrelevant. Verified working inside a program with **both**
`experimentalDecorators: true` and `emitDecoratorMetadata: true`.
You keep the compile-time guarantee where it matters — in the package where the rules are
written — and lose nothing at runtime.
## Support
Verified on the versions listed, on 2026-08-05. ✅ inline, ⚠️ via a precompiled package.
Rows marked — were not tested; they are listed so their absence is not mistaken for a verdict.
| Toolchain | | Notes |
| --- | --- | --- |
| `tsc` 5.2+ | ✅ | `experimentalDecorators: false`, `target: ES2022`+ |
| **Angular** 21 | ✅ | flip the scaffolded `experimentalDecorators` to `false` — Angular does not need it |
| **Vite** 8 | ✅ | add `cereale/vite`; covers React, Vue, Svelte, Solid, Qwik, Astro, Nuxt, SvelteKit |
| **Vitest** 4 | ✅ | same plugin |
| **Bun** 1.3 | ✅ | works with no configuration |
| esbuild 0.25 | ✅ | top-level `target: es2022`+ **and** `experimentalDecorators: false` |
| swc 1.15 | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
| **Next.js** 16 | ⚠️ | no inline support — see below |
| **NestJS** 11.1 | ⚠️ | its DI needs `emitDecoratorMetadata` — see below |
| Deno | — | untested |
| webpack + `ts-loader` | — | untested; follows whatever `tsc` is configured to do |
---
## Angular
The surprise is that Angular works. The CLI scaffolds `"experimentalDecorators": true`, but
Angular's compiler does not need it — `ngtsc` erases `@Component` and `@Injectable` into static
properties itself, rather than relying on TypeScript's decorator emit. Turn the flag off and
both work in the same program.
```jsonc
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": false, // was true; Angular does not need it
"target": "ES2022",
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
}
}
```
```ts
// user.dto.ts
import { IsString, MinLength, IsInt, Min, JsonProperty } from 'cereale';
export class UserDto {
@JsonProperty('display_name')
@IsString() @MinLength(3)
displayName!: string;
@IsInt() @Min(18)
age!: number;
}
```
```ts
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs';
import { toInstanceSync } from 'cereale';
import { UserDto } from './user.dto';
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
load(id: string) {
return this.http.get(`/api/users/${id}`).pipe(map(body => toInstanceSync(UserDto, body)));
}
}
```
Verified with `ngc` on Angular 21.2 and `strictTemplates: true`: the component's template is
still type-checked, and a wrong cereale rule is still a compile error inside the Angular build —
`@IsString()` on `age!: number` fails with TS1240 exactly as it does anywhere else.
## Any Vite app — React, Vue, Svelte, Solid, Astro, Nuxt, SvelteKit
Vite 8 transforms with oxc, which does not implement the standard decorator transform **and does
not report that**. `vite build` succeeds and leaves the decorator syntax in the bundle, which
throws the moment anything imports it. cereale ships the plugin that fixes it.
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; // or vue(), svelte(), solid()…
import { standardDecorators } from 'cereale/vite';
export default defineConfig({
plugins: [standardDecorators(), react()],
});
```
```jsonc
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": false,
"target": "ES2022",
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
}
}
```
```tsx
// UserForm.tsx
import { useState } from 'react';
import { toInstanceSync, validateSync, flattenErrors } from 'cereale';
import { UserDto } from './user.dto'; // a .ts file, not .tsx — see below
export function UserForm() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
function onSubmit(form: FormData) {
const draft = toInstanceSync(UserDto, Object.fromEntries(form), { validate: false });
setErrors(flattenErrors(validateSync(draft)));
}
// …
}
```
The plugin transforms `.ts`, `.mts` and `.cts` outside `node_modules`. `.tsx` is excluded by
default, because lowering decorators there means also deciding what happens to the JSX — keep
decorated classes in `.ts` files, or pass an `include` of your own:
```ts
standardDecorators({ include: (id) => /\.[cm]?tsx?$/.test(id) && !id.includes('/node_modules/') })
```
Options: `include`, `target` (default `es2022`), and `transformer` (`'auto'` prefers esbuild and
falls back to the TypeScript compiler; cereale depends on neither).
## Vitest
Same plugin, same reason — Vitest 4 uses the same oxc pipeline, and without it prints `0 test`
next to a bare `SyntaxError`.
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { standardDecorators } from 'cereale/vite';
export default defineConfig({ plugins: [standardDecorators()] });
```
## Node, with tsc
Nothing to configure beyond the flag.
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false,
"strictPropertyInitialization": false // optional; `field!: T` otherwise needs the `!`
}
}
```
```ts
import express from 'express';
import { fromJsonSync, JsonValidationError, flattenErrors } from 'cereale';
import { UserDto } from './user.dto.js';
app.post('/users', (req, res) => {
try {
const user = fromJsonSync(UserDto, JSON.stringify(req.body));
return res.status(201).json(user);
} catch (error) {
if (error instanceof JsonValidationError) {
return res.status(400).json({ errors: flattenErrors(error.errors) });
}
throw error;
}
});
```
## Bun
Works with no configuration — Bun's transpiler emits standard decorators when
`experimentalDecorators` is off, which is its default.
```bash
bun add cereale
bun run app.ts
```
## Next.js
**Not supported inline.** Next derives *both* the SWC parser's decorator support and its
transform mode from the single `experimentalDecorators` flag:
```js
const enableDecorators = Boolean(jsConfig?.compilerOptions?.experimentalDecorators);
// parser: decorators: enableDecorators
// transform: legacyDecorator: enableDecorators
```
So the flag on gives legacy emit that cereale refuses, and the flag off makes `@` a syntax
error. There is no third setting, and no exposed `decoratorVersion` option.
Use a precompiled models package instead:
```
repo/
models/ ← compiled by tsc, experimentalDecorators: false
package.json { "name": "@acme/models", "exports": { ".": "./dist/index.js" } }
tsconfig.json
src/user.dto.ts ← your cereale classes live here
web/ ← the Next app, untouched
package.json { "dependencies": { "@acme/models": "workspace:*" } }
```
```jsonc
// models/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "ESNext.Decorators"],
"experimentalDecorators": false,
"useDefineForClassFields": true,
"declaration": true,
"outDir": "dist"
}
}
```
Build `models` before `web`. Next only ever sees the compiled JavaScript — no decorator syntax
reaches its parser — and mapping and validation work normally. Verified against Next 16.3's own
SWC configuration.
## NestJS
**Not supported inline.** Nest scaffolds `experimentalDecorators: true` *and*
`emitDecoratorMetadata: true`, and its dependency injection genuinely needs the `design:type`
metadata that only legacy decorators emit. Turning the flag off breaks Nest.
The precompiled models package above works here too, and this is the case that proves the
pattern: a program compiled with **both** legacy flags on can import cereale DTOs from a
precompiled package and validate with them normally, alongside its own `@Injectable()` and
`@Inject()` decorators.
```ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { UserDto } from '@acme/models'; // precompiled
import { toInstanceSync, validateSync, formatErrors } from 'cereale';
@Injectable()
export class UsersService {
create(body: unknown) {
const draft = toInstanceSync(UserDto, body, { validate: false });
const errors = validateSync(draft);
if (errors.length) throw new BadRequestException(formatErrors(errors));
return draft;
}
}
```
If you would rather not split the package, stay on class-validator for now — Nest's own
`ValidationPipe` is built around it, and cereale does not try to replace that integration.
## Other bundlers
**esbuild** needs its own top-level `target`, not one inside `tsconfigRaw`. A `target` in
`tsconfigRaw` sets the `useDefineForClassFields` default and nothing else, so the decorators
are left in the output:
```js
await esbuild.build({
target: 'es2022', // ← required, and top-level
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
});
```
**swc** spells the choice as a proposal date:
```jsonc
// .swcrc
{
"jsc": {
"parser": { "syntax": "typescript", "decorators": true },
"transform": { "decoratorVersion": "2022-03" },
"target": "es2022"
}
}
```
## A single file, no bundler
`cereale/min` is the whole library flattened into one minified ES module (33.9 KB, 9.6 KB
gzipped) for import maps, `<script type="module">`, Deno and Workers.
**If you are using a bundler, do not use it.** It is the whole library in one file, so nothing
can be dropped from it. The default entry point tree-shakes — one decorator costs about 1.8 KB
against 26 KB for everything — and produces a smaller result in any real application. See
[Bundle size](README.md#bundle-size).
```html
<script type="importmap">
{ "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
</script>
```
+61 -2
View File
@@ -27,8 +27,9 @@ user.greet(); // your methods are still there
**[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an
interactive playground that runs this library in your browser, the full decorator reference,
and the toolchain matrix. The page is self-contained and loads nothing from the network; it is
served from `docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
and the toolchain matrix. The page has no third-party dependencies — no CDN, no analytics, no webfonts; the only thing it
fetches is its own vendored compiler, and only when you first press Run. It is served from
`docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
## Where it fits
@@ -108,6 +109,20 @@ inside a native binary with no standalone transform API.
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below |
### Frameworks
**[FRAMEWORKS.md](FRAMEWORKS.md)** has a setup recipe for each, every one of them run before it
was written. The short version:
| | | |
| --- | --- | --- |
| **Angular** 21 | ✅ | Flip the scaffolded `experimentalDecorators` to `false`. Angular does not need it — `ngtsc` erases its own decorators itself |
| **React, Vue, Svelte, Solid, Astro, Nuxt** | ✅ | Any Vite 8 app: add the `cereale/vite` plugin below |
| **Bun** | ✅ | No configuration |
| **Node** + `tsc` | ✅ | Just the flag |
| **Next.js** 16 | ⚠️ | Not inline: it derives both the SWC parser *and* the transform from one flag, so decorators either compile legacy or fail to parse. Keep your models in a package compiled by `tsc` |
| **NestJS** 11 | ⚠️ | Not inline: its DI needs `emitDecoratorMetadata`. Same precompiled-package route — verified working alongside `@Injectable()` in a program with both legacy flags on |
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
reports success while emitting a bundle that throws the moment it is imported. Cereale ships
@@ -480,6 +495,50 @@ costs a few percent on `toPlain`, which is the price of never emitting `{}` wher
used to be; primitives are handled inline and the check is skipped for arrays and dates, so
it is one `Symbol.toStringTag` read per object.
## Bundle size
Cereale tree-shakes. Every rule is declared so that a bundler can drop the ones you did not
import, which matters for a library with 68 decorators — you pay for what you name and nothing
else. Minified bytes, measured through esbuild, rollup and webpack. The table was measured by hand;
what is [pinned by a test](src/treeshake.test.ts) is the property behind it — that a given
import drops the parts of the library it does not reach — checked through esbuild on both the
source and the published bundle:
| What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: |
| `flattenErrors` | 394 | 367 | 394 |
| one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,738 |
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
| a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,360 |
| the whole library | 26,266 | 25,671 | 26,879 |
The serializer and the deserializer drop independently: read JSON and you do not pay for
writing it. The validator is kept by both, because `validate` defaults to `true` and the entry
points reference it whatever a given call site passes.
The floor is about a hundred bytes: cereale installs `Symbol.metadata` if the runtime lacks it,
and that install has to survive tree-shaking or a `tsc`-compiled consumer decorates its classes
with no metadata at all. It is why `sideEffects` names `index.js` as well as `metadata.js` —
marking only the latter leaves the barrel itself droppable, so the edge to it is pruned before
its own marking is ever read. That cost lands only on the first row; every import that touches a
model was already carrying it.
This did not come for free. Thirty of the rules are declared as top-level calls —
`export const IsString = rule(…)` — and rollup can prove such a call side-effect-free by reading
the factory, but esbuild and webpack will not. Without a `/*#__PURE__*/` annotation on each of
them, importing one decorator pulled in the message and validator of all 68: **4,909 bytes
instead of 1,837**. Nothing failed; the library was simply three times heavier in every
consumer's bundle, and the only way to find out was to measure. Note what that means for
measuring: rollup alone would have shown nothing wrong.
`cereale/min` tree-shakes too, which took a second fix — esbuild's `minify` strips comments,
annotations included, so the flat bundle was silently reproducing the same bug (5,066 bytes for
one decorator). It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw,
9.6 KB gzipped, about a kilobyte over the wire more than full minification would give. Still,
if you are using a bundler, import from `cereale` rather than `cereale/min`.
## Notes and Limitations
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
+2 -2
View File
File diff suppressed because one or more lines are too long
+21
View File
@@ -271,6 +271,7 @@ header.nav {
.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; } }
@@ -693,6 +694,26 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
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</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</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.
<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="dot" aria-hidden="true"></span><span class="name">vite.config.ts</span></div>
<pre><code data-lang="ts">import { defineConfig } from 'vite';
+1 -1
View File
@@ -1,5 +1,5 @@
// Generated by scripts/build-docs.mjs — do not edit.
window.CEREALE_META = {
"version": "0.3.0",
"version": "0.4.0",
"node": ">=20.0.0"
};
+11 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cereale",
"version": "0.3.0",
"version": "0.4.0",
"description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
"type": "module",
"main": "./dist/cjs/index.js",
@@ -16,21 +16,29 @@
"types": "./dist/esm/vite.d.ts",
"import": "./dist/esm/vite.js",
"require": "./dist/cjs/vite.js"
},
"./min": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/cereale.min.js"
}
},
"sideEffects": [
"./dist/esm/index.js",
"./dist/esm/metadata.js",
"./dist/cjs/metadata.js"
"./dist/cereale.min.js",
"./src/index.ts",
"./src/metadata.ts"
],
"files": [
"dist",
"src",
"FRAMEWORKS.md",
"CHANGELOG.md",
"!src/**/*.test.ts",
"!src/example.ts"
],
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node scripts/build-bundle.mjs",
"build:docs": "node scripts/build-docs.mjs",
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
"type-check": "tsc --noEmit",
+82
View File
@@ -0,0 +1,82 @@
/**
* Flattens the ESM build into a single minified module.
*
* This is an *addition*, not a replacement. `dist/esm` stays the default `import`: it keeps
* readable stack traces for anyone who does not load source maps, and it is what a bundler
* should be handed.
*
* Where the single file does earn its place is everywhere a bundler is not involved: a
* `<script type="module">` tag, a CDN, an import map, Deno, or a Worker. That is what
* `cereale/min` is for.
*
* It is built from `dist/esm/index.js` rather than from `src/`, so the decorator lowering and
* the ES2025 target are whatever `tsc` produced — esbuild is only flattening and minifying.
* (esbuild has no `es2025` target name; `esnext` means "downlevel nothing", which is what we
* want when the input is already at the target.)
*/
import { build } from 'esbuild';
import { readFile, writeFile, stat } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { gzipSync } from 'node:zlib';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const dist = path.join(root, 'dist');
const outfile = path.join(dist, 'cereale.min.js');
const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
await build({
entryPoints: [path.join(dist, 'esm/index.js')],
bundle: true,
format: 'esm',
target: 'esnext',
// NOT `minify: true`: `minifyWhitespace` strips comments, /*#__PURE__*/ included, and a
// bundler fed the result keeps every unused rule — 5,066 bytes for one decorator against
// 1,837. Asserted below. Costs about a kilobyte gzipped.
minifySyntax: true,
minifyIdentifiers: true,
sourcemap: true,
legalComments: 'none',
banner: { js: `/*! cereale ${pkg.version} | MIT | ${pkg.homepage} */` },
// The input is compiled JavaScript, so the project's tsconfig has no bearing here — and
// reading it only earns a warning, because esbuild does not know the ES2025 target name
// that tsc is perfectly happy with.
tsconfigRaw: {},
outfile,
});
// The whole reason this file is not fully minified. Asserting it here means a future change to
// the minify options fails the build rather than silently tripling what a bundler keeps.
//
// The floor on `expected` is not decoration: comparing the two counts alone passes vacuously if
// tsc ever stops emitting the annotations, since 0 >= 0. It has to be wrong in both directions.
const count = (s) => (s.match(/__PURE__/g) ?? []).length;
const emitted = await readFile(outfile, 'utf8');
const annotations = count(emitted);
const expected = count(await readFile(path.join(dist, 'esm/decorators.js'), 'utf8'));
if (expected < 30) {
throw new Error(
`dist/esm/decorators.js carries only ${expected} /*#__PURE__*/ annotations; src/decorators.ts ` +
'writes 30. The compiler is dropping them, so every consumer keeps all 68 rules.'
);
}
if (annotations < expected) {
throw new Error(
`the flat bundle kept ${annotations} /*#__PURE__*/ annotations but dist/esm/decorators.js has ` +
`${expected}. Minification stripped them, so anything bundling cereale/min would keep every ` +
'unused rule. Do not turn on minifyWhitespace here.'
);
}
// dist/cjs/package.json is the nearest descriptor for every module beneath it, so bundlers
// read `sideEffects` from there rather than from the root manifest. Declaring it only at the
// root leaves the whole CommonJS build undeclared.
await writeFile(
path.join(dist, 'cjs/package.json'),
JSON.stringify({ type: 'commonjs', sideEffects: ['./metadata.js'] }, null, 2) + '\n'
);
const size = (await stat(outfile)).size;
const gzip = gzipSync(emitted).length;
console.log(`dist/cereale.min.js ${(size / 1024).toFixed(1)} KB (${(gzip / 1024).toFixed(1)} KB gzipped)`);
+30 -30
View File
@@ -262,17 +262,17 @@ export function ValidateNested(options?: ValidationOptions): FieldDecorator<obje
// Type rules
// ============================================================================
export const IsString: Rule<string> = rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
export const IsNumber: Rule<number> = rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`);
export const IsInt: Rule<number> = rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
export const IsBoolean: Rule<boolean> = rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
export const IsBigInt: Rule<bigint> = rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
export const IsDate: Rule<Date> = rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`);
export const IsObject: Rule<object> = rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
export const IsString: Rule<string> = /*#__PURE__*/ rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
export const IsNumber: Rule<number> = /*#__PURE__*/ rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`);
export const IsInt: Rule<number> = /*#__PURE__*/ rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
export const IsBoolean: Rule<boolean> = /*#__PURE__*/ rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
export const IsBigInt: Rule<bigint> = /*#__PURE__*/ rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
export const IsDate: Rule<Date> = /*#__PURE__*/ rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`);
export const IsObject: Rule<object> = /*#__PURE__*/ rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
export const IsDefined: Rule<unknown> = rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`);
export const IsNotEmpty: Rule<unknown> = rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
export const IsEmpty: Rule<unknown> = rule('isEmpty', v => {
export const IsDefined: Rule<unknown> = /*#__PURE__*/ rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`);
export const IsNotEmpty: Rule<unknown> = /*#__PURE__*/ rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
export const IsEmpty: Rule<unknown> = /*#__PURE__*/ rule('isEmpty', v => {
if (v === null || v === undefined || v === '') return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === 'object') return Object.keys(v).length === 0;
@@ -301,8 +301,8 @@ export function Max(max: number, options?: ValidationOptions): unknown {
}), options);
}
export const Positive: Rule<number> = rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
export const Negative: Rule<number> = rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`);
export const Positive: Rule<number> = /*#__PURE__*/ rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
export const Negative: Rule<number> = /*#__PURE__*/ rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`);
export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each<number>;
export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One<number>;
@@ -315,13 +315,13 @@ export function IsDivisibleBy(divisor: number, options?: ValidationOptions): unk
}
/** An integer in 0..65535. Accepts a number or a numeric string. */
export const IsPort: Rule<number | string> = rule('isPort', v => {
export const IsPort: Rule<number | string> = /*#__PURE__*/ rule('isPort', v => {
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
}, p => `${p} must be a valid port number`);
export const IsLatitude: Rule<number> = rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`);
export const IsLongitude: Rule<number> = rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`);
export const IsLatitude: Rule<number> = /*#__PURE__*/ rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`);
export const IsLongitude: Rule<number> = /*#__PURE__*/ rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`);
// ============================================================================
// Strings
@@ -358,22 +358,22 @@ export function Length(min: number, max?: number, options?: ValidationOptions):
}), options);
}
export const Email: Rule<string> = pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
export const IsAlpha: Rule<string> = pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
export const IsAlphanumeric: Rule<string> = pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`);
export const IsSemVer: Rule<string> = pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`);
export const IsHexColor: Rule<string> = pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`);
export const Email: Rule<string> = /*#__PURE__*/ pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
export const IsAlpha: Rule<string> = /*#__PURE__*/ pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
export const IsAlphanumeric: Rule<string> = /*#__PURE__*/ pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`);
export const IsSemVer: Rule<string> = /*#__PURE__*/ pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`);
export const IsHexColor: Rule<string> = /*#__PURE__*/ pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`);
export const IsLowercase: Rule<string> = rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`);
export const IsUppercase: Rule<string> = rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
export const IsNumberString: Rule<string> = rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`);
export const IsDateString: Rule<string> = rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`);
export const IsJSON: Rule<string> = rule('isJson', v => {
export const IsLowercase: Rule<string> = /*#__PURE__*/ rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`);
export const IsUppercase: Rule<string> = /*#__PURE__*/ rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
export const IsNumberString: Rule<string> = /*#__PURE__*/ rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`);
export const IsDateString: Rule<string> = /*#__PURE__*/ rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`);
export const IsJSON: Rule<string> = /*#__PURE__*/ rule('isJson', v => {
if (typeof v !== 'string') return false;
try { JSON.parse(v); return true; } catch { return false; }
}, p => `${p} must be a JSON string`);
export const IsUrl: Rule<string> = rule('isUrl', v => {
export const IsUrl: Rule<string> = /*#__PURE__*/ rule('isUrl', v => {
try { new URL(v as string); return true; } catch { return false; }
}, p => `${p} must be a valid URL`);
@@ -449,10 +449,10 @@ function affix(name: string, test: (value: string, seed: string) => boolean, des
return decorator;
}
export const Contains = affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`);
export const NotContains = affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`);
export const StartsWith = affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`);
export const EndsWith = affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`);
export const Contains = /*#__PURE__*/ affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`);
export const NotContains = /*#__PURE__*/ affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`);
export const StartsWith = /*#__PURE__*/ affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`);
export const EndsWith = /*#__PURE__*/ affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`);
// ============================================================================
// Equality and membership
+7 -2
View File
@@ -15,8 +15,13 @@ import type { ClassConstructor } from './interfaces.js';
*/
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata`
// directly. package.json marks this module as having side effects so it survives bundling.
// Also installed globally: tsc's decorator emit reads `Symbol.metadata` directly rather than
// falling back the way we do — `typeof Symbol === "function" && Symbol.metadata ? … : void 0` —
// so without this a decorated class gets `metadata: undefined` and no rules at all.
//
// `sideEffects` in package.json keeps the statement through bundling, and must name `index.*`
// as well as this module: marking only this one leaves the barrel droppable, so the edge to it
// is pruned before this marking is ever read. Pinned by treeshake.test.ts.
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
export interface ValidationArguments {
+201
View File
@@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest';
import { build } from 'esbuild';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
/**
* Tree-shakability is a property of the source that nothing else notices when it breaks.
*
* Every rule is declared as a top-level call — `export const IsString = rule(...)` — and rollup
* can prove such a call pure by reading the factory, but esbuild and webpack will not. Without
* the `/*#__PURE__*\/` annotations on those declarations, importing one decorator dragged in the
* message and validator of all 68: 4909 bytes rather than 1837 through esbuild, 4823 rather
* than 1818 through webpack. Nothing failed. The library simply got three times heavier in
* every consumer's bundle, and the only way to notice was to go and measure.
*
* So these assertions are mostly about *content* rather than bytes: a byte ceiling tells you
* something drifted, but naming the thing that should not be there says what.
*/
/** Markers that identify a chunk of the library in minified output. */
const MARKER = {
isString: 'must be a string',
minLength: 'must be longer than or equal to',
isLatitude: 'must be a latitude',
isSemVer: 'must be a valid semantic version',
arraySize: 'must contain at least',
serializer: 'Circular reference',
representable: 'cannot be serialized to JSON',
deserializer: 'Unknown property',
validator: '[redacted]',
naming: 'SCREAMING_SNAKE_CASE',
} as const;
const ENTRY = JSON.stringify(path.resolve('src/index.js'));
async function bundle(source: string): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-shake-'));
try {
const entry = path.join(dir, 'entry.ts');
await writeFile(entry, source);
const result = await build({
entryPoints: [entry],
bundle: true,
format: 'esm',
minify: true,
target: 'es2022',
write: false,
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
});
return result.outputFiles[0]!.text;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/**
* Asserts what a bundle kept and what it dropped.
*
* `keeps` is not decoration. A bundle that failed to build, or that resolved the library as an
* external and inlined none of it, contains none of the markers — so an "everything was shaken"
* result and a broken harness look identical without it.
*/
function expectShaken(code: string, keeps: string[], drops: string[]) {
expect(code.length, 'the bundle is empty — the harness is broken, not the tree-shaking').toBeGreaterThan(200);
for (const marker of keeps) {
expect(code, `expected the bundle to contain ${JSON.stringify(marker)}`).toContain(marker);
}
for (const marker of drops) {
expect(code, `${JSON.stringify(marker)} should have been shaken out`).not.toContain(marker);
}
}
describe('tree-shaking', () => {
it('drops the 67 rules you did not import', async () => {
const code = await bundle(`
import { IsString } from ${ENTRY};
export const d = IsString();
`);
expectShaken(code, [MARKER.isString], [
MARKER.isLatitude, MARKER.isSemVer, MARKER.arraySize, MARKER.minLength,
MARKER.serializer, MARKER.deserializer, MARKER.naming,
]);
// Generous ceiling: the measured figure is ~1.8 KB, and this is here to catch a regression
// of the kind above (which trebled it), not to police every byte.
expect(code.length).toBeLessThan(3000);
});
it('keeps the deserializer and drops the serializer when only reading', async () => {
const code = await bundle(`
import { toInstanceSync } from ${ENTRY};
export const f = (C, p) => toInstanceSync(C, p, { validate: false });
`);
// Validation is kept on purpose: `validate` defaults to true, so the entry point
// references it whatever the call site passes.
expectShaken(code, [MARKER.deserializer, MARKER.validator], [MARKER.serializer, MARKER.representable]);
});
it('keeps the serializer and drops the deserializer when only writing', async () => {
const code = await bundle(`
import { toPlainSync } from ${ENTRY};
export const f = (o) => toPlainSync(o, { validate: false });
`);
expectShaken(code, [MARKER.serializer, MARKER.representable, MARKER.validator], [MARKER.deserializer]);
});
it('drops both engines when only validating', async () => {
const code = await bundle(`
import { validateSync } from ${ENTRY};
export const f = (o) => validateSync(o);
`);
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
});
/**
* The `Symbol.metadata` install at the top of metadata.ts is a bare statement, not an export,
* so it survives only because `sideEffects` names that module — and naming it is one hop short
* of enough. The barrel that re-exports it is side-effect-free too, so a bundler prunes the
* `export * from './metadata.js'` edge before metadata.js's own marking is ever consulted.
*
* Nothing notices, in the usual way. `import { configure } from 'cereale'` came out at 145
* bytes through esbuild, 143 through webpack and 144 through rollup with `Symbol.metadata`
* absent from all three — and a tsc-compiled consumer on a runtime without the well-known
* symbol is then decorated with `metadata: undefined`, which is to say with no rules at all.
*/
it('installs Symbol.metadata even when nothing model-shaped is imported', async () => {
const code = await bundle(`
import { configure } from ${ENTRY};
export const f = (o) => configure(o);
`);
expect(code, 'the Symbol.metadata install was pruned along with the barrel').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer, MARKER.validator]);
});
it('costs almost nothing to import only an error helper', async () => {
const code = await bundle(`
import { flattenErrors } from ${ENTRY};
export const f = (e) => flattenErrors(e);
`);
expectShaken(code, [], [MARKER.serializer, MARKER.deserializer, MARKER.validator, MARKER.isString]);
expect(code.length).toBeLessThan(1500);
});
it('still contains everything when everything is used', async () => {
const code = await bundle(`
import * as cereale from ${ENTRY};
export default cereale;
`);
// The counterweight to every assertion above: proves the markers are findable at all, so a
// "shaken" result upstream means shaken rather than misspelled.
expectShaken(code, Object.values(MARKER), []);
});
/**
* The cases above bundle `src/`, which is where the annotations are written — so they cannot
* see what happens to them on the way into `dist/`. That is exactly where this broke: the
* flat bundle behind `cereale/min` was built with esbuild's `minify: true`, whose
* `minifyWhitespace` pass strips comments, annotations included. The published entry point
* kept all 26 unrelated rules (5,066 bytes against 1,837) while every source-level check
* stayed green.
*/
describe('the published artifacts', () => {
const dist = path.resolve('dist');
const built = existsSync(path.join(dist, 'cereale.min.js'));
it.runIf(built)('cereale/min tree-shakes as well as the per-module entry', async () => {
const code = await bundle(`
import { IsString } from ${JSON.stringify(path.join(dist, 'cereale.min.js'))};
export const d = IsString();
`);
expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]);
expect(code.length).toBeLessThan(3000);
});
/**
* The source-level case above proves the `sideEffects` mechanism works; this one proves the
* two entries are spelled the way the published tree is laid out. `./src/index.ts` and
* `./dist/esm/index.js` are separate paths in the manifest, and a typo in either is invisible
* from the other side.
*/
it.runIf(built)('installs Symbol.metadata from the published barrel too', async () => {
const code = await bundle(`
import { configure } from ${JSON.stringify(path.join(dist, 'esm/index.js'))};
export const f = (o) => configure(o);
`);
expect(code, 'the Symbol.metadata install was pruned from dist/esm').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer]);
});
});
});
+5 -5
View File
@@ -42,7 +42,7 @@ export class JsonMappingError extends Error {
* next steps in a pollution chain. This library exists to parse request bodies, so the
* transform layer drops them rather than trusting callers to sanitise first.
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
const FORBIDDEN_KEYS = /*#__PURE__*/ new Set(['__proto__', 'constructor', 'prototype']);
/**
* Stands in for the value of a property that is never serialized, so that a failing password
@@ -96,7 +96,7 @@ interface OutboundProperty {
serializer?: any;
}
const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
const outboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
/**
* Resolves how one property is written out, memoized per (prototype, naming strategy).
@@ -167,7 +167,7 @@ interface InboundNames {
// Name maps are derived purely from decorator metadata, which is fixed once a class is
// declared, so they are cached per (prototype, naming strategy).
const inboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
const inboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
/**
* Builds the JSON-name -> property-key lookup used when reading a payload.
@@ -614,7 +614,7 @@ interface CachedPlan {
// Turning a class model into a per-property plan is cheap, but doing it on every call was
// measurably not: profiling showed roughly half of all validation time re-deriving answers
// that cannot change. Plans are memoized per model and invalidated by the model version.
const planCache = new WeakMap<ClassModel, CachedPlan>();
const planCache = /*#__PURE__*/ new WeakMap<ClassModel, CachedPlan>();
/**
* Collapses rules that are genuinely identical.
@@ -663,7 +663,7 @@ function validationPlan(model: ClassModel): PropertyPlan[] {
// Serializers and deserializers are stateless by contract, so one instance per class is
// enough. Constructing a fresh one for every property of every object was pure waste.
const converterCache = new WeakMap<object, any>();
const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
function converterFor(clazz: any): any {
let instance = converterCache.get(clazz);