11 Commits
Author SHA1 Message Date
Senrokai 50c08aa556 Merge pull request #3 from avalon-vanguard/claude/sync-api-and-redaction
Synchronous API and write-only redaction

Nothing on the default path is genuinely asynchronous - only a serializer,
deserializer or validator the caller supplies can be - so requiring await
everywhere taxed the common case. Rather than duplicating the traversal into a
second sync copy, which would drift from the original, the engines are now
written synchronously and anything a hook makes asynchronous is recorded and
reconciled once at the end. A *Sync call that meets a Promise raises a
JsonMappingError naming the async alternative.

Adds validateSync, validateOrRejectSync, toPlainSync, toJsonSync, toInstanceSync,
toInstanceArraySync, fromJsonSync and fromJsonArraySync. fromRequest has no
synchronous form, since reading a request body is inherently async.

Removing the per-property await also sped up the async path. Combined with the
plan caching from #2, against JSON.parse + JSON.stringify (5.8 us) as a fixed
reference:

  validate    (50 orders)   221.6 us -> 17.8 us   12.4x
  validate    (10 orders)    47.8 us ->  4.5 us   10.6x
  toPlain     (50 orders)   294.4 us -> 36.0 us    8.2x
  toInstance  (50 orders)   255.1 us -> 31.7 us    8.0x

A @JsonWriteOnly password that failed @MinLength put the rejected password into
ValidationError.value, and from there into any log recording the error - with a
plausible route to an HTTP response, since the README recommends flattening those
errors into a 400 body. Values of properties that never leave the process are now
replaced with the exported REDACTED placeholder; property name and failure
message are unchanged.

176 tests, up from 150, adding coverage of async hooks through the async API that
the suite had never exercised. That caught a regression this change introduced,
where an async serializer's deferred write changed property order in the output.

No breaking changes. Green on Node 20, 22 and 24.
2026-08-04 19:54:15 +02:00
Claude 6d30182ca8 ✨ feat: add a synchronous API and redact write-only values from errors
Synchronous API
---------------
Nothing on the default path is genuinely asynchronous - only a serializer,
deserializer or validator the caller supplies can be - so requiring `await`
everywhere taxed the common case.

Rather than duplicating the traversal into a second sync copy (the traversal is
exactly where the eight defects fixed in 0.1.0 lived, and two copies would drift),
the engines are now written synchronously and anything a hook makes asynchronous
is recorded and reconciled once at the end. A `*Sync` call that encounters a
Promise raises a JsonMappingError naming the async alternative instead of
returning a half-built object.

Adds validateSync, validateOrRejectSync, toPlainSync, toJsonSync, toInstanceSync,
toInstanceArraySync, fromJsonSync, fromJsonArraySync. fromRequest has no
synchronous form, since reading a request body is inherently async.

Removing the per-property await also sped up the async path substantially. With
the plan caching from the previous release, against JSON.parse + JSON.stringify
(5.8 us) as a fixed reference:

  validate    (50 orders)   221.6 us -> 17.8 us   12.4x
  validate    (10 orders)    47.8 us ->  4.5 us   10.6x
  toPlain     (50 orders)   294.4 us -> 36.0 us    8.2x
  toInstance  (50 orders)   255.1 us -> 31.7 us    8.0x
  toInstance  (single)       19.8 us ->  5.2 us    3.8x

Write-only redaction
--------------------
A @JsonWriteOnly password that failed @MinLength put the rejected password into
ValidationError.value, and from there into any log that recorded the error.
Values of properties that never leave the process - @JsonWriteOnly and
@JsonIgnore - are now replaced with the exported REDACTED placeholder. The
property name and failure message are unchanged, so the error stays actionable.

Tests
-----
176 tests, up from 150. The new suite covers the sync family, its refusal of
async hooks (including that refusing does not leave an unhandled rejection), and
async hooks through the async API - serializers, deserializers, validators, and
async validators under each: true, which the suite had never exercised.

That last group caught a regression this change introduced: with an async
serializer the deferred write appended its key after the synchronous ones,
changing property order in the output. The slot is now claimed before deferring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-04 11:56:05 +00:00
Senrokai 203e5ec27b Merge pull request #2 from avalon-vanguard/claude/consolidation-perf-reliability
Memoize per-class plans (3-4.5x faster), add depth guard and per-index each reporting

Profiling showed roughly half of all validation time re-deriving answers that
cannot change - collectConstraints 22%, getOwnMetadata 12%, getMetadataChain 9%,
getProperties 4%, getMetadata 3%, plus 8% GC - while the constraint predicates
themselves accounted for under 1%.

Decorator metadata is fixed once classes are declared, so the validation,
serialization and deserialization plans are now memoized per prototype, along
with serializer/deserializer instances that were previously constructed for every
property of every object. A version counter on MetadataStorage invalidates the
caches when metadata is written, so registerDecorator after first use still takes
effect.

  validate    (50 orders)   221.6 us -> 49.6 us   4.5x
  validate    (10 orders)    47.8 us -> 12.9 us   3.7x
  toInstance  (50 orders)   255.1 us -> 74.0 us   3.4x
  toPlain     (50 orders)   294.4 us -> 95.3 us   3.1x

Also bounds recursion with a maxDepth option (default 64) across all three
engines, closing a stack-exhaustion vector on hostile payloads, and makes
each: true failures name the element that failed.

136 -> 150 tests, no breaking changes, green on Node 20, 22 and 24.
2026-08-04 13:41:49 +02:00
Claude 6e458fdd43 ⚡ perf: memoize per-class plans; add depth guard and per-index each reporting
Profiling the validator showed roughly half of all validation time re-deriving
answers that cannot change — collectConstraints 22%, getOwnMetadata 12%,
getMetadataChain 9%, getProperties 4%, getMetadata 3%, plus 8% GC from the
allocation churn. The constraint predicates themselves were under 1%.

Decorator metadata is fixed once classes are declared, so the derived structures
are now memoized per prototype: the validation plan, the serialization plan, the
deserialization plan, and serializer/deserializer instances, which were being
constructed fresh for every property of every object. MetadataStorage carries a
version counter that invalidates the caches when metadata is written, so
registerDecorator after first use still works — covered by a test.

Measured against JSON.parse + JSON.stringify as a fixed reference:

  validate    (50 orders)   221.6 us -> 49.6 us   4.5x
  validate    (10 orders)    47.8 us -> 12.9 us   3.7x
  toInstance  (50 orders)   255.1 us -> 74.0 us   3.4x
  toInstance  (10 orders)    64.6 us -> 19.0 us   3.4x
  toPlain     (50 orders)   294.4 us -> 95.3 us   3.1x

Reliability, in the same pass:

- maxDepth option (default 64) on every mapping function, on validate(), and on
  configure(). All three engines recurse, so a payload nested thousands of levels
  deep could exhaust the call stack. Cycles were already handled; legitimate deep
  nesting was not bounded.
- each: true failures now name the element that failed ("failed at index 3"). A
  bad entry in a 200-item array previously produced a message that could not
  locate it. A message function now receives the failing element as args.value
  rather than the whole array; caller-supplied strings stay verbatim.

150 tests (up from 136), all green on the existing suite unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-04 11:36:53 +00:00
Senrokai 26cd6b5707 Merge pull request #1 from avalon-vanguard/claude/library-development-i46yqm
Repair the test suite, fix eight engine defects, and add field-name mapping (0.1.0)

The test suite had never executed: vitest 4 transpiles with oxc, which does not
read experimentalDecorators from a tsconfig excluding the files it transforms,
so every decorator suite failed to parse and was reported as "0 test". Fixing
that revived 40 tests and exposed eight engine defects, each now pinned by a
regression test: inheritance silently discarding base-class constraints, a
circular reference exhausting the heap, stateful /g regexes in @Matches, an
unmatched polymorphic discriminator dropping data, serializers crashing on unset
optional properties, null-prototype objects, __proto__ from untrusted JSON, and
mangled or overwritten error messages.

Adds field-name mapping (@JsonProperty, @JsonAlias, naming strategies), access
control (@JsonIgnore, @JsonReadOnly, @JsonWriteOnly), transform options,
error-flattening helpers, and 30 validation decorators.

40 tests that never ran -> 136 that do, at 97% statement and 100% function
coverage, green on Node 20, 22 and 24.
2026-08-04 13:20:48 +02:00
Claude 2acdf3b360 🔧 ci: run the pipeline on develop as well as main
The PR now targets develop, which was branched from main. The workflow only
triggered on push and pull_request against main, so nothing would have run on
develop or on any future PR into it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-04 11:14:58 +00:00
Claude 83ad2a289d 📝 docs: bring README, demo and playground in line with the code; release 0.1.0
The README described a library that did not exist in places. It showed
@ValidateNested({ each: true }), which did not compile; told users to enable
emitDecoratorMetadata, which the library never reads; and documented none of the
mapping API. Its Quick Start now runs verbatim — verified by compiling and
executing it against the local source.

- README: document field-name mapping, access control, options, error helpers
  and the 30 new validators; drop the emitDecoratorMetadata instruction; add a
  Notes and Limitations section covering circular references, validate() on
  plain objects, and the fact that @JsonProperty stops the original name from
  being accepted unless you add @JsonAlias
- CHANGELOG.md: new, covering 0.1.0
- example.ts: rewritten as a tour of the current API — read-only ids, write-only
  secrets, renamed fields, conditional validation, flattened errors, and a
  base-class rule reaching a subclass
- docs: the playground hand-listed its symbol table in three parallel places and
  exposed IsEmail, which is not an export. It now derives scope from the bundle,
  so new decorators work there as soon as they ship. Bundle regenerated
- version 0.1.0

136 tests, 97% statement and 100% function coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-03 23:53:09 +00:00
Claude 44c8f28f4b ✨ feat: add 30 validation decorators, @ValidateIf and @Allow
Rounds out the validator set with the rules an application actually reaches for,
all following the existing decorator style and honouring each/message options.

- equality and presence: @Equals, @NotEquals, @IsEmpty, @IsEnum, @IsInstance
- strings: @Length, @IsAlpha, @IsAlphanumeric, @IsNumberString, @IsLowercase,
  @IsUppercase, @Contains, @NotContains, @StartsWith, @EndsWith
- formats: @IsUUID, @IsJSON, @IsDateString, @IsSemVer, @IsHexColor, @IsIP
- numbers: @IsDivisibleBy, @IsPort, @IsLatitude, @IsLongitude, @IsBigInt
- dates: @MinDate, @MaxDate
- arrays: @ArrayUnique, @ArrayContains, @ArrayNotContains

@ValidateIf(o => ...) makes a property's rules conditional on the rest of the
object, and @Allow() declares a property that needs no rules of its own so it
survives unknownKeys: 'strip'.

Two details worth noting. @IsEnum filters the reverse mapping a numeric enum
compiles to, so 'Low' is not accepted as a value of enum { Low, High }.
@MinDate/@MaxDate accept a thunk, so "not in the past" is evaluated per
validation instead of being frozen when the class was declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-03 23:48:40 +00:00
Claude 666762a146 ✨ feat: add field-name mapping, access control and transform options
A library whose headline feature is "JSON mapping" could not map a name: there
was no way to read {"first_name": ...} into firstName, no way to keep a password
out of the response, and no way to parse a payload without validating it.

Name mapping
- @JsonProperty(name) renames a property in both directions
- @JsonAlias(...names) accepts extra names on input only, so a field can be
  renamed without breaking older clients
- naming strategies (snake_case, kebab-case, SCREAMING_SNAKE_CASE, PascalCase,
  camelCase, or your own function) for properties with no explicit name.
  Acronyms split where a reader expects: parseHTTPResponse -> parse_http_response

Access control
- @JsonIgnore()    excluded both ways
- @JsonWriteOnly() accepted from input, never echoed back (passwords)
- @JsonReadOnly()  serialized, never settable by a client (server-owned ids)

Blocked names are dropped explicitly rather than falling through to the unknown
key path, which would otherwise have copied a rejected id straight back on under
the default policy.

Transform options, per call or globally via configure()
- validate: false to map without validating, for lenient parsing
- unknownKeys: 'allow' | 'strip' | 'error'
- namingStrategy

Error ergonomics — the nested ValidationError tree was hard to turn into an HTTP
400 body. flattenErrors() yields {"items[0].qty": ["qty must be at least 1"]},
plus formatErrors() and collectErrorMessages(). Adds validateOrReject().

All defaults preserve existing behaviour; the 68 prior tests pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-03 23:46:18 +00:00
Claude 6d04b43964 🐛 fix: repair eight correctness defects in the mapping and validation engines
Each fix is pinned by a regression test in src/regressions.test.ts describing the
old behaviour.

- Inheritance dropped base-class rules. A subclass re-decorating an inherited
  property registered its constraints against its own prototype, and validate()
  read only the nearest set, so everything the base declared was silently lost.
  Constraints are now merged down the whole prototype chain, base first, with
  genuinely identical rules collapsed so restating @IsString() on an override
  does not double-report. The library's own example.ts was affected: Media's
  @IsString() title had never been enforced for Book.
- Circular references exhausted the heap. serialize() recursed forever, taking
  8 GB and the process with it; it now tracks ancestors and raises a
  JsonMappingError naming the cause. Diamonds still serialize. validate() skips
  back-edges instead of recursing.
- @Matches with a g or y flag was stateful: RegExp.test advances lastIndex, so
  validating the same value twice gave different answers. Those flags are
  stripped.
- An unmatched @JsonPolymorphic discriminator silently dropped the value — the
  single-object branch fell through without assigning. The raw value is now
  preserved, with { onUnknown: 'error' } and { fallback } to choose otherwise.
- Custom @JsonSerialize serializers ran on null/undefined, crashing on any unset
  optional property. They now only see real values.
- serialize() read obj.constructor.prototype, which throws for null-prototype
  objects; both engines now agree on Object.getPrototypeOf.
- __proto__, constructor and prototype arriving in untrusted JSON were copied
  onto the instance, detaching it from its own class. They are dropped.
- The "each element in ..." prefix was glued onto caller-supplied messages, and
  two rules sharing a name overwrote each other so only one failure surfaced.

Also adds toInstanceArray/fromJsonArray, since toInstance and fromJson accept
arrays at runtime but type the result as T, and reports a non-JSON request body
in fromRequest as a JsonMappingError rather than a raw SyntaxError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-03 23:41:10 +00:00
Claude 66e19f690f 🔧 fix: repair test, build and CI infrastructure
The vitest suite silently ran zero tests: vitest 4 transpiles with oxc, which
does not pick up `experimentalDecorators` from a tsconfig that excludes the
files being transformed, so every decorator-using suite failed to parse and was
reported as "0 test". A vitest.config.ts enabling legacy decorators brings all
40 existing tests back to life.

- Add vitest.config.ts (oxc legacy decorators + v8 coverage config)
- Type-check test files: move the test/demo exclusions from the base tsconfig
  onto the two build configs, and fix the strict-mode errors this surfaced
- Stop shipping src/example.ts in dist (it invokes runExample() at import time,
  a side effect in a package declaring "sideEffects": false)
- CI: run lint, tests with coverage, build and entry-point smoke checks; drop
  EOL Node 18, add Node 24; commit package-lock.json so `npm ci` works
- Add `verify`, `test:watch` and `build:docs` scripts, and an engines field;
  `build:docs` regenerates the previously hand-maintained docs/cereale.js

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-03 23:14:16 +00:00
28 changed files with 7588 additions and 392 deletions
+16 -4
View File
@@ -2,17 +2,19 @@ name: CI
on: on:
push: push:
branches: [ main ] branches: [ main, develop ]
pull_request: pull_request:
branches: [ main ] branches: [ main, develop ]
jobs: jobs:
build: verify:
name: Node ${{ matrix.node-version }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
node-version: [18.x, 20.x, 22.x] node-version: [20.x, 22.x, 24.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -25,5 +27,15 @@ jobs:
run: npm ci run: npm ci
- name: Type Check - name: Type Check
run: npm run type-check run: npm run type-check
- name: Lint
run: npm run lint
- name: Test
run: npm run test:coverage
- name: Build
run: npm run build
- name: Verify published entry points load
run: |
node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');"
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
- name: Run Demo - name: Run Demo
run: npm run demo run: npm run demo
+2 -1
View File
@@ -1,6 +1,7 @@
# Node modules and dependency files # Node modules and dependency files
# NOTE: package-lock.json is intentionally committed — CI installs with `npm ci`,
# which requires a lockfile to be present in the repository.
/node_modules/ /node_modules/
/package-lock.json
# Build outputs # Build outputs
/dist/ /dist/
+184
View File
@@ -0,0 +1,184 @@
# Changelog
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).
## [Unreleased]
### Added
**Synchronous API.** `validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`,
`toInstanceSync`, `toInstanceArraySync`, `fromJsonSync` and `fromJsonArraySync`. Nothing on
the default path is genuinely asynchronous — only a user-supplied serializer, deserializer or
validator can be — so requiring `await` everywhere was a tax on the common case.
The engines are now written synchronously, and anything a hook makes asynchronous is recorded
and reconciled once at the end. There is no second copy of the traversal logic to keep in
step, and the async entry points stop paying for a microtask per property. If a hook does
return a Promise, the `*Sync` call raises a `JsonMappingError` naming the async alternative
rather than silently returning a half-built object.
`fromRequest` has no synchronous counterpart, because reading a request body is inherently
asynchronous.
- `maxDepth` option (default 64) on every mapping function and on `configure()`. All three
engines recurse, so a hostile payload nested thousands of levels deep could exhaust the
call stack; it now raises a `JsonMappingError`. Cycles were already handled, but legitimate
deep nesting was not bounded.
- `validate(obj, options?)` accepts options, so `maxDepth` applies to standalone validation.
- `REDACTED` export, the placeholder substituted for withheld values.
### Security
- **Validation errors no longer carry the value of a property that is never serialized.**
A `@JsonWriteOnly` password that failed `@MinLength` put the rejected password into
`ValidationError.value`, and from there into any log that recorded the error. Values for
`@JsonWriteOnly` and `@JsonIgnore` properties are replaced with `REDACTED`; the property
name and the failure message are unchanged, so the error is still actionable.
### Performance
Profiling the validator showed roughly **half of all validation time** was spent re-deriving
answers that cannot change: `collectConstraints` (22%), `getOwnMetadata` (12%),
`getMetadataChain` (9%), `getProperties` (4%) and `getMetadata` (3%), plus 8% garbage
collection from the allocation churn. The constraint predicates themselves accounted for
under 1%.
Decorator metadata is fixed once classes are declared, so the derived structures are now
memoized per prototype — the validation plan, the serialization plan, the deserialization
plan, and serializer/deserializer instances (previously constructed fresh for every property
of every object). `MetadataStorage` carries a version counter that invalidates every cache
if metadata is registered late, so `registerDecorator` after first use still works.
Together with the synchronous core, measured on a customer record with a nested address and
orders, against `JSON.parse` + `JSON.stringify` (5.8 us) as a fixed reference point:
| Operation | 0.1.0 | Now | Speedup |
| --- | --- | --- | --- |
| `validate` (50 orders) | 221.6 us | 17.8 us | 12.4x |
| `validate` (10 orders) | 47.8 us | 4.5 us | 10.6x |
| `toPlain` (50 orders) | 294.4 us | 36.0 us | 8.2x |
| `toInstance` (50 orders) | 255.1 us | 31.7 us | 8.0x |
| `toInstance` (10 orders) | 64.6 us | 8.2 us | 7.9x |
| `toInstance` (single) | 19.8 us | 5.2 us | 3.8x |
### Changed
- `each: true` failures now report which element failed — `"... (failed at index 3)"`. A bad
entry in a 200-item array previously produced a message that could not locate it. A message
function now receives the failing element as `args.value` rather than the whole array;
caller-supplied string messages are still reported verbatim.
## [0.1.0] - 2026-08-03
The first release with a working test suite. Everything below the "Fixed" heading was
found by writing tests against the previous release; the suite has grown from 40 tests
that never executed to 136 that do.
### Added
**Field-name mapping.** A library whose headline feature is "JSON mapping" could not map
a name. It can now.
- `@JsonProperty(name)` renames a property in both directions.
- `@JsonAlias(...names)` accepts extra names on input only, so a field can be renamed
without breaking older clients.
- Naming strategies — `snake_case`, `kebab-case`, `SCREAMING_SNAKE_CASE`, `PascalCase`,
`camelCase`, or your own function — applied to properties with no explicit name.
Acronyms split where a reader expects: `parseHTTPResponse` → `parse_http_response`.
**Access control.**
- `@JsonIgnore()` — excluded in both directions.
- `@JsonWriteOnly()` — accepted from input, never echoed back (passwords).
- `@JsonReadOnly()` — serialized, never settable by a client (server-owned ids).
**Transform options**, per call or globally via `configure()`.
- `validate: false` maps without validating, for lenient parsing.
- `unknownKeys: 'allow' | 'strip' | 'error'` decides what happens to undeclared keys.
- `namingStrategy` selects the JSON naming convention.
**Error ergonomics.** Turning the nested `ValidationError` tree into an HTTP 400 body used
to be the caller's problem.
- `flattenErrors(errors)` → `{ "items[0].qty": ["qty must be at least 1"] }`
- `formatErrors(errors)` → one human-readable line per failure
- `collectErrorMessages(errors)` → just the messages
- `validateOrReject(obj)` throws instead of returning an array you might forget to check
**30 validation decorators.** `@Equals`, `@NotEquals`, `@IsEmpty`, `@IsEnum`, `@IsInstance`,
`@Length`, `@IsAlpha`, `@IsAlphanumeric`, `@IsNumberString`, `@IsLowercase`, `@IsUppercase`,
`@Contains`, `@NotContains`, `@StartsWith`, `@EndsWith`, `@IsUUID`, `@IsJSON`,
`@IsDateString`, `@IsSemVer`, `@IsHexColor`, `@IsIP`, `@IsDivisibleBy`, `@IsPort`,
`@IsLatitude`, `@IsLongitude`, `@IsBigInt`, `@MinDate`, `@MaxDate`, `@ArrayUnique`,
`@ArrayContains`, `@ArrayNotContains`.
**Conditional validation.** `@ValidateIf(o => ...)` makes a property's rules depend on the
rest of the object; `@Allow()` declares a property that needs no rules of its own.
**Correctly typed array entry points.** `toInstanceArray()` and `fromJsonArray()`.
`toInstance`/`fromJson` accept arrays at runtime but type the result as `T`, so callers had
to cast to reach the elements.
**`@JsonPolymorphic` options.** `{ onUnknown: 'error' }` and `{ fallback: SomeClass }`.
**`JsonMappingError`** — raised when a value cannot be mapped at all, as distinct from
mapping fine and failing validation.
### Fixed
- **Inheritance silently discarded base-class rules.** A subclass re-decorating an inherited
property registered its constraints against its own prototype, and the engine read only the
nearest set. Constraints now merge down the whole prototype chain, base first. The
library's own example was affected: `Media`'s `@IsString() title` had never been enforced
for `Book`.
- **A circular reference exhausted the heap.** `serialize()` recursed forever, taking 8 GB
and the process with it. It now raises a `JsonMappingError` naming the cause. Diamonds
still serialize; `validate()` skips back-edges.
- **`@Matches` with a `g` or `y` flag was stateful.** `RegExp.test` advances `lastIndex`, so
validating the same value twice gave different answers. Those flags are stripped.
- **An unmatched `@JsonPolymorphic` discriminator silently dropped the value.** The
single-object branch fell through without assigning; the property came back `undefined`.
The raw value is now preserved.
- **`@JsonSerialize` serializers ran on `null`/`undefined`**, crashing on any unset optional
property. They now only see real values.
- **`serialize()` crashed on null-prototype objects.** It read `obj.constructor.prototype`;
both engines now agree on `Object.getPrototypeOf`.
- **`__proto__`, `constructor` and `prototype` in untrusted JSON** were copied onto the
instance, detaching it from its own class. They are dropped.
- **Caller-supplied messages were mangled** by the `each element in ...` prefix, producing
sentences like "each element in tags must all be strings".
- **Two rules sharing a name overwrote each other**, so only one failure was ever reported.
- **`fromRequest` leaked a raw `SyntaxError`** for a non-JSON body; it now reports a
`JsonMappingError`.
- **`@ValidateNested({ each: true })`** was documented in the README but did not compile —
`ValidateNested()` accepted no arguments. It now does, and asserts the value is an array.
### Changed
- `toPlain`, `toJson`, `toInstance`, `fromJson`, `fromJsonArray`, `toInstanceArray` and
`fromRequest` accept an optional trailing options argument. All defaults preserve the
previous behaviour.
- `src/example.ts` is no longer published in `dist`. It called `runExample()` at import
time — an import side effect in a package declaring `"sideEffects": false`.
- Minimum supported Node is 20.
### Infrastructure
- **The test suite had never run.** Vitest 4 transpiles with oxc, which does not read
`experimentalDecorators` from a tsconfig that excludes the files it is transforming, so
every decorator-using suite failed to parse and was reported as "0 test" rather than as an
error. A `vitest.config.ts` enabling legacy decorators brought all 40 existing tests back
to life.
- Test files are now type-checked, which surfaced 17 strict-mode errors.
- CI runs lint, coverage tests, build and ESM/CJS entry-point smoke checks across Node
20/22/24, and `npm ci` works because `package-lock.json` is committed.
- `npm run build:docs` regenerates the previously hand-maintained `docs/cereale.js`.
## [0.0.1]
Initial release: mapping and validation decorators, polymorphic types, custom
serializers/deserializers, and the `toJson` / `fromJson` / `toPlain` / `toInstance` API.
+234 -48
View File
@@ -4,10 +4,12 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
## Features ## Features
- **Spring-like Decorators:** Familiar `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`. - **Spring-like Decorators:** Familiar `@JsonProperty`, `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
- **Field-name Mapping:** Map `first_name` to `firstName` per property or with a naming strategy.
- **Access Control:** Keep passwords out of responses and server-owned ids out of requests.
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects. - **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
- **Polymorphism Support:** Native handling of polymorphic types via discriminators. - **Polymorphism Support:** Native handling of polymorphic types via discriminators.
- **Integrated Validation:** Automatically validates objects during serialization and deserialization. - **Integrated Validation:** 50+ validation decorators, applied during mapping or on demand.
- **Type Safety:** Fully written in TypeScript for excellent developer experience. - **Type Safety:** Fully written in TypeScript for excellent developer experience.
- **Zero Dependencies:** Extremely lightweight and fast. - **Zero Dependencies:** Extremely lightweight and fast.
@@ -17,29 +19,27 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
npm install cereale npm install cereale
``` ```
Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your `tsconfig.json`: Enable `experimentalDecorators` in your `tsconfig.json`:
```json ```json
{ {
"compilerOptions": { "compilerOptions": {
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "target": "ES2022"
"target": "ES2025"
} }
} }
``` ```
Cereale stores its own metadata, so `reflect-metadata` is not required and
`emitDecoratorMetadata` is not read. Requires Node 20 or later.
## Quick Start ## Quick Start
### 1. Define your Models ### 1. Define your Models
Use decorators to define how your data should be transformed and validated.
```typescript ```typescript
import { import {
IsString, IsString,
IsInt,
Min,
IsDate, IsDate,
ValidateNested, ValidateNested,
JsonSerialize, JsonSerialize,
@@ -52,7 +52,7 @@ import {
// Custom Date Serializer // Custom Date Serializer
class DateSerializer implements JsonSerializer<Date, string> { class DateSerializer implements JsonSerializer<Date, string> {
serialize(value: Date): string { serialize(value: Date): string {
return value.toISOString().split('T')[0]; return value.toISOString().split('T')[0]!;
} }
} }
@@ -94,12 +94,13 @@ class Library {
} }
``` ```
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s
`@IsString() title` as well as its own rules.
### 2. Map JSON with Validation ### 2. Map JSON with Validation
Use standalone utility functions to handle the conversion process directly.
```typescript ```typescript
import { fromJson, toJson, JsonValidationError } from 'cereale'; import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale';
async function main() { async function main() {
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}'; const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
@@ -111,11 +112,11 @@ async function main() {
console.log(library.items[0] instanceof Book); // true console.log(library.items[0] instanceof Book); // true
// Serialize Class Instance back to JSON // Serialize Class Instance back to JSON
const outputJson = await toJson(library); console.log(await toJson(library));
console.log(outputJson);
} catch (error) { } catch (error) {
if (error instanceof JsonValidationError) { if (error instanceof JsonValidationError) {
console.error("Validation failed:", error.errors); console.error(flattenErrors(error.errors));
// { "items[0].title": ["title must be a string"] }
} }
} }
} }
@@ -135,20 +136,123 @@ app.post('/books', async (c) => {
}); });
``` ```
## Field-name Mapping
JSON rarely uses the same names as your classes.
```typescript
import { JsonProperty, JsonAlias } from 'cereale';
class User {
@JsonProperty('first_name')
firstName: string; // <-> {"first_name": "Ada"}
@JsonProperty('surname')
@JsonAlias('last_name') // also accepted on input, never emitted
lastName: string;
}
```
Or convert every property at once with a naming strategy:
```typescript
import { configure, toPlain } from 'cereale';
// once, for the whole application
configure({ namingStrategy: 'snake_case' });
// or per call
await toPlain(user, { namingStrategy: 'snake_case' });
```
Built-in strategies: `identity` (default), `camelCase`, `PascalCase`, `snake_case`,
`SCREAMING_SNAKE_CASE`, `kebab-case`. You can also pass your own
`(propertyKey: string) => string`. An explicit `@JsonProperty` always wins.
Acronyms split where a reader expects them to: `parseHTTPResponse` becomes
`parse_http_response`, not `parse_h_t_t_p_response`.
## Access Control
```typescript
import { JsonIgnore, JsonReadOnly, JsonWriteOnly } from 'cereale';
class Account {
@JsonReadOnly() // sent to clients, never settable by them
id: number;
@IsString()
email: string;
@JsonWriteOnly() // accepted from clients, never echoed back
@IsString()
password: string;
@JsonIgnore() // never crosses the boundary in either direction
internalNotes: string;
}
```
## Options
Every mapping function takes an optional trailing options argument, and `configure()` sets
defaults for the whole application. Per-call options win.
| Option | Values | Default | Meaning |
| --- | --- | --- | --- |
| `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. |
| `namingStrategy` | strategy name or function | `identity` | JSON naming convention for properties without `@JsonProperty`. |
| `unknownKeys` | `allow` \| `strip` \| `error` | `allow` | What to do with incoming keys matching no declared property. |
| `maxDepth` | `number` | `64` | Nesting depth before a `JsonMappingError` is raised, bounding hostile payloads. |
```typescript
// lenient parse: build the instance, inspect the damage yourself
const draft = await fromJson(Order, body, { validate: false });
const problems = flattenErrors(await validate(draft));
// strict intake: reject anything you did not declare
const order = await fromJson(Order, body, { unknownKeys: 'error' });
```
## Synchronous API
Nothing on the default path is genuinely asynchronous — only a serializer, deserializer or
validator you supply can be — so every mapping function has a synchronous twin.
```typescript
import { fromJsonSync, toJsonSync, validateSync } from 'cereale';
const user = fromJsonSync(User, body); // no await
const errors = validateSync(user);
const payload = toJsonSync(user);
```
`validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`, `toInstanceSync`,
`toInstanceArraySync`, `fromJsonSync`, `fromJsonArraySync`.
If one of your hooks does return a Promise, the synchronous call raises a `JsonMappingError`
naming the async function to use instead, rather than handing back a half-built object.
`fromRequest` has no synchronous form, since reading a request body is inherently async.
## API Reference ## API Reference
### Decorators ### Mapping Decorators
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Specifies a custom serializer for a property. - `@JsonProperty(name: string)`: Renames the property in JSON, both directions.
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Specifies a custom deserializer for a property. - `@JsonAlias(...names: string[])`: Extra names accepted on input only.
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations. - `@JsonIgnore()`: Excludes the property from mapping entirely.
- `@JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])`: Configures polymorphic transformation based on a discriminator field. - `@JsonReadOnly()`: Serialized, but never populated from incoming JSON.
- `@JsonWriteOnly()`: Populated from incoming JSON, but never serialized.
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Custom serializer for a property. Skipped when the value is `null`/`undefined`.
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Custom deserializer for a property.
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations. Applies element-wise to arrays.
- `@JsonPolymorphic(discriminator, subTypes, options?)`: Polymorphic transformation based on a discriminator field. `options` accepts `{ onUnknown: 'keep' | 'error' }` (default `keep`, which preserves the raw value) and `{ fallback: ClassConstructor }`.
#### Validation Decorators ### Validation Decorators
Most validation decorators accept an optional `ValidationOptions` object: Most validation decorators accept an optional `ValidationOptions` object:
- `each: boolean`: Apply validation to each element of an array. - `each: boolean`: Apply validation to each element of an array.
- `message: string | ((args: ValidationArguments) => string)`: Custom error message. - `message: string | ((args: ValidationArguments) => string)`: Custom error message, reported verbatim.
| Decorator | Description | | Decorator | Description |
| --- | --- | | --- | --- |
@@ -156,46 +260,95 @@ Most validation decorators accept an optional `ValidationOptions` object:
| `@IsNumber()` | Checks if value is a number (and not NaN). | | `@IsNumber()` | Checks if value is a number (and not NaN). |
| `@IsInt()` | Checks if value is an integer. | | `@IsInt()` | Checks if value is an integer. |
| `@IsBoolean()` | Checks if value is a boolean. | | `@IsBoolean()` | Checks if value is a boolean. |
| `@IsBigInt()` | Checks if value is a bigint. |
| `@IsObject()` | Checks if value is an object (not null/array). | | `@IsObject()` | Checks if value is an object (not null/array). |
| `@IsDate()` | Checks if value is a valid Date object. | | `@IsDate()` | Checks if value is a valid Date object. |
| `@IsDefined()` | Checks if value is not null or undefined. | | `@IsDefined()` | Checks if value is not null or undefined. |
| `@IsOptional()` | Skips other validations if value is null/undefined. | | `@IsOptional()` | Skips other validations if value is null/undefined. |
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. | | `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
| `@Min(value)` | Checks if number is >= value. | | `@IsEmpty()` | Checks if value is null/undefined/`''`/`[]`/`{}`. |
| `@Max(value)` | Checks if number is <= value. | | `@Equals(value)` / `@NotEquals(value)` | Strict equality against a fixed value. |
| `@Positive()` | Checks if number is > 0. | | `@IsEnum(enumObject)` | Checks membership of a TypeScript enum. |
| `@Negative()` | Checks if number is < 0. | | `@IsInstance(Class)` | Checks `value instanceof Class`. |
| `@MinLength(len)` | Checks if string length is >= len. | | `@Min(value)` / `@Max(value)` | Numeric bounds. |
| `@MaxLength(len)` | Checks if string length is <= len. | | `@Positive()` / `@Negative()` | Checks sign. |
| `@IsDivisibleBy(n)` | Checks `value % n === 0`. |
| `@IsPort()` | Integer in 0–65535, as number or numeric string. |
| `@IsLatitude()` / `@IsLongitude()` | Geographic bounds. |
| `@MinLength(len)` / `@MaxLength(len)` | String length bounds. |
| `@Length(min, max?)` | Both bounds in one rule. |
| `@IsAlpha()` / `@IsAlphanumeric()` | Character-class checks. |
| `@IsLowercase()` / `@IsUppercase()` | Case checks. |
| `@IsNumberString()` | String that parses as a finite number. |
| `@Contains(s)` / `@NotContains(s)` | Substring checks. |
| `@StartsWith(s)` / `@EndsWith(s)` | Affix checks. |
| `@Email()` | Checks if string is a valid email. | | `@Email()` | Checks if string is a valid email. |
| `@IsUrl()` | Checks if string is a valid URL. | | `@IsUrl()` | Checks if string is a valid URL. |
| `@Matches(regex)`| Checks if string matches a regular expression. | | `@IsUUID(version?)` | Checks if string is a valid UUID. |
| `@IsIP(version?)` | Checks if string is a valid IPv4/IPv6 address. |
| `@IsJSON()` | Checks if string parses as JSON. |
| `@IsDateString()` | Checks if string is a parseable date. |
| `@IsSemVer()` | Checks if string is a semantic version. |
| `@IsHexColor()` | Checks `#rgb`, `#rrggbb`, `#rrggbbaa`. |
| `@Matches(regex)` | Checks if string matches a regular expression. |
| `@MinDate(d)` / `@MaxDate(d)` | Date bounds. Accepts `() => Date` for a moving bound. |
| `@IsArray()` | Checks if value is an array. | | `@IsArray()` | Checks if value is an array. |
| `@ArrayNotEmpty()`| Checks if array is not empty. | | `@ArrayNotEmpty()` | Checks if array is not empty. |
| `@ArrayMinSize(n)`| Checks if array has at least n elements. | | `@ArrayMinSize(n)` / `@ArrayMaxSize(n)` | Array size bounds. |
| `@ArrayMaxSize(n)`| Checks if array has at most n elements. | | `@ArrayUnique(keyFn?)` | Checks for duplicate elements. |
| `@IsIn(values)` | Checks if value is in the allowed list. | | `@ArrayContains(vals)` / `@ArrayNotContains(vals)` | Membership checks. |
| `@IsNotIn(vals)` | Checks if value is NOT in the list. | | `@IsIn(values)` / `@IsNotIn(values)` | Allow/deny lists. |
| `@ValidateNested()`| Recursively validates nested objects/arrays. | | `@ValidateNested(options?)` | Recursively validates nested objects/arrays. |
| `@ValidateIf(o => boolean)` | Skips this property's rules when the condition is false. |
| `@Allow()` | Declares a property with no rules of its own. |
| `@Validate(validator, constraints?, options?)` | Applies a custom validator class or function. |
Write your own with `registerDecorator({ name, target, propertyName, validator })`.
### Utilities ### Utilities
- `toJson(obj: any)`: Validates and serializes an instance to a JSON string (Returns `Promise<string>`). - `toJson(obj, options?)`: Validates and serializes an instance to a JSON string (`Promise<string>`).
- `toPlain(obj: any)`: Validates and transforms an instance to a plain object (Returns `Promise<any>`). - `toPlain(obj, options?)`: Validates and transforms an instance to a plain object (`Promise<any>`).
- `fromJson(clazz: ClassConstructor, json: string)`: Parses JSON and transforms it to a validated class instance (Returns `Promise<T>`). - `fromJson(clazz, json, options?)`: Parses JSON to a validated class instance (`Promise<T>`).
- `toInstance(clazz: ClassConstructor, plain: any)`: Transforms a plain object to a validated class instance (Returns `Promise<T>`). - `fromJsonArray(clazz, json, options?)`: Same, for a JSON array (`Promise<T[]>`).
- `fromRequest(clazz: ClassConstructor, request: Request)`: Extracts JSON from a Fetch `Request` and transforms it to a validated instance (Returns `Promise<T>`). - `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
- `validate(obj: any)`: Performs full validation on an object/instance (Returns `Promise<ValidationError[]>`). - `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
- `validate(obj, options?)`: Full validation, returning `Promise<ValidationError[]>`.
- `validateOrReject(obj, options?)`: As above, but throws `JsonValidationError`.
- Synchronous twins of all of the above except `fromRequest`: `toPlainSync`, `toJsonSync`,
`fromJsonSync`, `fromJsonArraySync`, `toInstanceSync`, `toInstanceArraySync`,
`validateSync`, `validateOrRejectSync`.
- `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults.
### Error Handling
`JsonValidationError` carries a nested `ValidationError[]`. Three helpers turn it into
something you can return to a client:
```typescript
import { flattenErrors, formatErrors, collectErrorMessages } from 'cereale';
flattenErrors(errors); // { "items[0].qty": ["qty must be at least 1"] }
formatErrors(errors); // "items[0].qty: qty must be at least 1"
collectErrorMessages(errors); // ["qty must be at least 1"]
```
Values of properties that never leave the process — `@JsonWriteOnly` and `@JsonIgnore` — are
replaced with `REDACTED` in `ValidationError.value`, so a rejected password does not travel
into your logs inside an error object. The property name and message are unaffected.
`JsonMappingError` is raised when a value cannot be mapped at all — a body that is not
JSON, a circular reference, an unknown discriminator under `{ onUnknown: 'error' }` — as
distinct from mapping fine and failing validation.
## Framework Integrations ## Framework Integrations
Cereale is designed to be compatible with all trending web frameworks.
### Hono / Next.js / Cloudflare Workers ### Hono / Next.js / Cloudflare Workers
Use `fromRequest` for seamless integration with the Fetch `Request` API. Use `fromRequest` for seamless integration with the Fetch `Request` API.
### NestJS ### NestJS
You can use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`. Use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`.
```typescript ```typescript
import { toInstance } from 'cereale'; import { toInstance } from 'cereale';
@@ -208,21 +361,54 @@ async create(@Body() body: any) {
``` ```
### Express / Fastify ### Express / Fastify
Easily integrate with traditional Node.js frameworks.
```typescript ```typescript
import { toInstance, toPlain } from 'cereale'; import { toInstance, toPlain, JsonValidationError, flattenErrors } from 'cereale';
app.post('/user', async (req, res) => { app.post('/user', async (req, res) => {
try { try {
const user = await toInstance(User, req.body); const user = await toInstance(User, req.body);
res.json(await toPlain(user)); res.json(await toPlain(user));
} catch (err) { } catch (err) {
res.status(400).json(err); if (err instanceof JsonValidationError) {
return res.status(400).json({ errors: flattenErrors(err.errors) });
}
throw err;
} }
}); });
``` ```
## Performance
Decorator metadata is fixed once your classes are declared, so cereale resolves each class's
validation, serialization and deserialization plans once and memoizes them per prototype.
A version counter invalidates the caches if metadata is registered late, so `registerDecorator`
after first use still behaves correctly. The engines are synchronous internally, so the async
entry points do not pay for a microtask per property.
Indicative throughput for a customer record with a nested address and 10 orders, measured
against `JSON.parse` + `JSON.stringify` (5.8 us) on the same machine:
| Operation | Time |
| --- | --- |
| `toInstance` (deserialize + validate) | ~8 us |
| `toInstance` with `{ validate: false }` | ~3 us |
| `validate` on an existing instance | ~4.5 us |
| `toPlain` (validate + serialize) | ~12 us |
If you validate at the edge and map internally afterwards, `{ validate: false }` skips the
dominant cost.
## Notes and Limitations
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
the cycle with `@JsonIgnore()` on the back-reference.
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
the instance you get back from `toInstance`, not the raw payload.
- **Renaming is not backwards-compatible by itself.** Once a property carries
`@JsonProperty`, its original name is no longer accepted on input — add `@JsonAlias` to
keep older clients working.
## Contributing ## Contributing
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
+2 -1
View File
File diff suppressed because one or more lines are too long
+41 -41
View File
@@ -136,12 +136,23 @@
<div> <div>
<h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3> <h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3>
<ul class="space-y-2 text-slate-600"> <ul class="space-y-2 text-slate-600">
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonProperty('first_name')</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonAlias(...names)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonSerialize(cls)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonSerialize(cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonDeserialize(cls)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonDeserialize(cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonType(() => cls)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonType(() => cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonPolymorphic(field, types)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonPolymorphic(field, types)</code></li>
</ul> </ul>
</div> </div>
<div>
<h3 class="font-bold text-lg mb-4 text-indigo-600">Access Control</h3>
<ul class="space-y-2 text-slate-600">
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonIgnore()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonReadOnly()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonWriteOnly()</code></li>
<li class="text-sm pt-2">Naming strategies: <code class="text-sm bg-slate-100 p-1 rounded">snake_case</code>, <code class="text-sm bg-slate-100 p-1 rounded">kebab-case</code>, …</li>
</ul>
</div>
<div> <div>
<h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3> <h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3>
<ul class="space-y-2 text-slate-600"> <ul class="space-y-2 text-slate-600">
@@ -158,7 +169,9 @@
<li><code class="text-sm bg-slate-100 p-1 rounded">@Min(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Max(n)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@Min(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Max(n)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinLength(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@MaxLength(n)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@MinLength(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@MaxLength(n)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@Email()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsUrl()</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@Email()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsUrl()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@ValidateNested()</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@IsUUID(v?)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsEnum(e)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinDate(d)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@ArrayUnique()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@ValidateNested()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@ValidateIf(fn)</code></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -175,9 +188,10 @@
<script> <script>
const initialCode = `// 1. Define your model with decorators const initialCode = `// 1. Define your model with decorators
class User { class User {
@JsonProperty('display_name')
@IsString() @IsString()
@MinLength(3) @MinLength(3)
name; displayName;
@IsInt() @IsInt()
@Min(18) @Min(18)
@@ -186,26 +200,36 @@ class User {
@Email() @Email()
email; email;
constructor(name, age, email) { // Accepted from a request, never sent back out
this.name = name; @JsonWriteOnly()
@IsString()
password;
constructor(displayName, age, email, password) {
this.displayName = displayName;
this.age = age; this.age = age;
this.email = email; this.email = email;
this.password = password;
} }
} }
async function demo() { async function demo() {
console.log("--- Validating valid user ---"); console.log("--- Mapping a valid user ---");
const user = new User("Alice", 25, "alice@example.com"); const user = new User("Alice", 25, "alice@example.com", "hunter2");
const json = await JsonMapper.toJson(user); const json = await toJson(user);
console.log("JSON Output:", json); console.log("JSON Output:", json);
console.log("Password withheld:", !json.includes("hunter2"));
console.log("\\n--- Testing validation failure ---"); console.log("\\n--- Reading it back ---");
const parsed = await fromJson(User, json, { validate: false });
console.log("displayName read from display_name:", parsed.displayName);
console.log("\\n--- Reporting validation failures ---");
try { try {
const invalidJson = '{"name": "Bo", "age": 15, "email": "not-an-email"}'; await fromJson(User, '{"display_name": "Bo", "age": 15, "email": "nope", "password": "x"}');
await JsonMapper.fromJson(User, invalidJson);
} catch (error) { } catch (error) {
console.log("Caught Error:", error.message); console.log("Caught:", error.message);
console.log("Validation Errors:", JSON.stringify(error.errors, null, 2)); console.log("Flattened:", flattenErrors(error.errors));
} }
} }
@@ -247,36 +271,12 @@ demo();`;
] ]
}).code; }).code;
// Create a function with the library symbols in scope // Put every library export in scope. Derived from the bundle rather than
const { // hand-listed, so a new decorator is usable here the moment it is exported.
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested, const exportNames = Object.keys(Cereale).filter(name => /^[A-Za-z_$][\w$]*$/.test(name));
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
Positive, Negative,
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
JsonValidationError
} = Cereale;
const run = new Function( const run = new Function('console', ...exportNames, transpiled);
'console', await run({ log: logToOutput }, ...exportNames.map(name => Cereale[name]));
'IsString', 'IsInt', 'Min', 'Max', 'IsEmail', 'IsArray', 'IsDate', 'IsOptional', 'ValidateNested',
'IsBoolean', 'IsNumber', 'IsObject', 'IsDefined', 'IsNotEmpty', 'MinLength', 'MaxLength',
'Email', 'IsUrl', 'Matches', 'ArrayMinSize', 'ArrayMaxSize', 'ArrayNotEmpty', 'IsIn', 'IsNotIn',
'Positive', 'Negative',
'JsonSerialize', 'JsonDeserialize', 'JsonPolymorphic', 'JsonType', 'JsonMapper',
'JsonValidationError',
transpiled
);
await run(
{ log: logToOutput },
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested,
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
Positive, Negative,
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
JsonValidationError
);
} catch (err) { } catch (err) {
outputElement.textContent += 'Error: ' + err.message + '\\n'; outputElement.textContent += 'Error: ' + err.message + '\\n';
if (err.stack) { if (err.stack) {
+3258
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "cereale", "name": "cereale",
"version": "0.0.1", "version": "0.1.0",
"description": "Spring-like decorators for JSON mapping and validation in TypeScript", "description": "Spring-like decorators for JSON mapping and validation in TypeScript",
"type": "module", "type": "module",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
@@ -19,13 +19,19 @@
], ],
"scripts": { "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 && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
"build:docs": "esbuild src/index.ts --bundle --format=iife --global-name=Cereale --minify --tsconfig=tsconfig.json --outfile=docs/cereale.js",
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts", "demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"prepublishOnly": "npm run build" "verify": "npm run type-check && npm run lint && npm run test && npm run build",
"prepublishOnly": "npm run verify"
},
"engines": {
"node": ">=20.0.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -49,6 +55,7 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@vitest/coverage-v8": "^4.1.4", "@vitest/coverage-v8": "^4.1.4",
"esbuild": "^0.25.0",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"globals": "^17.5.0", "globals": "^17.5.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
+85
View File
@@ -0,0 +1,85 @@
import { NamingStrategy } from './naming.js';
/**
* How an incoming key that maps to no known property should be treated.
*
* - `allow` (default): copy it onto the instance untouched, preserving the previous behaviour.
* - `strip`: drop it, so instances only ever carry declared properties.
* - `error`: reject the payload with a {@link JsonMappingError}.
*/
export type UnknownKeyPolicy = 'allow' | 'strip' | 'error';
export interface TransformOptions {
/**
* Validate the result and throw {@link JsonValidationError} on failure.
*
* Defaults to `true`, matching the behaviour of every previous release. Set it to `false`
* to map without validating — useful when you want to inspect a partially-valid payload,
* or when validation happens elsewhere in your stack.
*/
validate?: boolean;
/**
* Naming convention used on the JSON side for properties without an explicit
* `@JsonProperty`. Defaults to `identity` (property names are used as-is).
*/
namingStrategy?: NamingStrategy;
/** What to do with incoming keys that match no declared property. Deserialization only. */
unknownKeys?: UnknownKeyPolicy;
/**
* Maximum nesting depth before a {@link JsonMappingError} is raised. Defaults to 64.
*
* All three engines recurse, so a hostile payload nested thousands of levels deep would
* otherwise exhaust the call stack. Raise it if you legitimately model deep trees.
*/
maxDepth?: number;
}
/** Options that can be set once for the whole application via {@link configure}. */
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate' | 'maxDepth'>;
const DEFAULTS: Required<GlobalOptions> = {
namingStrategy: 'identity',
unknownKeys: 'allow',
validate: true,
maxDepth: 64,
};
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
/**
* Sets library-wide defaults, so an application that consistently speaks `snake_case` does
* not have to repeat itself at every call site.
*
* ```ts
* configure({ namingStrategy: 'snake_case', unknownKeys: 'strip' });
* ```
*
* Per-call options always take precedence over these.
*/
export function configure(options: GlobalOptions): void {
globalOptions = { ...globalOptions, ...options };
}
/** Returns the current library-wide defaults. */
export function getConfig(): Required<GlobalOptions> {
return { ...globalOptions };
}
/** Restores the library-wide defaults to their original values. */
export function resetConfig(): void {
globalOptions = { ...DEFAULTS };
}
/** Merges per-call options over the library-wide defaults. */
export function resolveOptions(options?: TransformOptions): Required<GlobalOptions> {
if (!options) return globalOptions;
return {
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
validate: options.validate ?? globalOptions.validate,
maxDepth: options.maxDepth ?? globalOptions.maxDepth,
};
}
+7 -7
View File
@@ -238,7 +238,7 @@ describe('Additional Decorators', () => {
t.val = 'wrong'; t.val = 'wrong';
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['CustomValidator']).toBe('val must be correct'); expect(errors[0]!.constraints['CustomValidator']).toBe('val must be correct');
}); });
}); });
@@ -304,7 +304,7 @@ describe('Additional Decorators', () => {
t.val = 5; t.val = 5;
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be ten'); expect(errors[0]!.constraints['custom']).toBe('must be ten');
}); });
it('should handle options as second argument', async () => { it('should handle options as second argument', async () => {
@@ -318,7 +318,7 @@ describe('Additional Decorators', () => {
t.val = 2; t.val = 2;
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be one'); expect(errors[0]!.constraints['custom']).toBe('must be one');
}); });
}); });
@@ -339,10 +339,10 @@ describe('Additional Decorators', () => {
name: string; name: string;
} }
const json = '[{"name": "a"}, {"name": "b"}]'; const json = '[{"name": "a"}, {"name": "b"}]';
const items = await JsonMapper.fromJson(Item, json); const items = (await JsonMapper.fromJson(Item, json)) as unknown as Item[];
expect(Array.isArray(items)).toBe(true); expect(Array.isArray(items)).toBe(true);
expect(items[0]).toBeInstanceOf(Item); expect(items[0]).toBeInstanceOf(Item);
expect(items[0].name).toBe('a'); expect(items[0]!.name).toBe('a');
}); });
it('should handle single polymorphic object', async () => { it('should handle single polymorphic object', async () => {
@@ -350,7 +350,7 @@ describe('Additional Decorators', () => {
@IsString() type: string; @IsString() type: string;
} }
class Dog extends Animal { class Dog extends Animal {
type = 'dog'; override type = 'dog';
@IsString() breed: string; @IsString() breed: string;
} }
class Test { class Test {
@@ -376,7 +376,7 @@ describe('Additional Decorators', () => {
t.tags = ['a', 1 as any]; t.tags = ['a', 1 as any];
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['isString']).toContain('each element'); expect(errors[0]!.constraints['isString']).toContain('each element');
}); });
}); });
}); });
+625 -7
View File
@@ -9,8 +9,23 @@ export const METADATA_KEYS = {
DESERIALIZER: 'cereale:deserializer', DESERIALIZER: 'cereale:deserializer',
POLYMORPHIC: 'cereale:polymorphic', POLYMORPHIC: 'cereale:polymorphic',
IS_OPTIONAL: 'cereale:optional', IS_OPTIONAL: 'cereale:optional',
NESTED: 'cereale:nested',
NAME: 'cereale:name',
ALIASES: 'cereale:aliases',
ACCESS: 'cereale:access',
CONDITION: 'cereale:condition',
}; };
/**
* Which directions a property participates in.
*
* - `readwrite` (default): mapped both ways.
* - `readonly`: written to JSON, never populated from incoming JSON (server-assigned ids).
* - `writeonly`: populated from incoming JSON, never written back out (passwords).
* - `none`: ignored entirely.
*/
export type PropertyAccess = 'readwrite' | 'readonly' | 'writeonly' | 'none';
export interface ValidationArguments { export interface ValidationArguments {
value: any; value: any;
object: any; object: any;
@@ -29,6 +44,12 @@ export type ValidationConstraint = {
message: string | ((args: ValidationArguments) => string); message: string | ((args: ValidationArguments) => string);
constraints?: any[]; constraints?: any[];
each?: boolean; each?: boolean;
/**
* True when the message came from the user via `ValidationOptions.message`.
* The engine only decorates default messages with the "each element in ..." prefix;
* a message the user wrote is reported exactly as written.
*/
hasCustomMessage?: boolean;
}; };
export interface ValidatorConstraintInterface { export interface ValidatorConstraintInterface {
@@ -55,6 +76,7 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
} }
if (options.message) { if (options.message) {
constraint.message = options.message; constraint.message = options.message;
constraint.hasCustomMessage = true;
} }
} }
@@ -65,6 +87,86 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
// --- Mapping Decorators --- // --- Mapping Decorators ---
/**
* @JsonProperty(name: string)
* Maps this property to a different name in JSON, in both directions.
*
* ```ts
* class User {
* @JsonProperty('first_name')
* firstName: string; // <-> {"first_name": "Ada"}
* }
* ```
*
* An explicit name always wins over the active naming strategy.
*/
export function JsonProperty(name: string) {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.NAME, name, target, propertyKey);
};
}
/**
* @JsonAlias(...names: string[])
* Additional names accepted for this property when reading JSON.
*
* Aliases are input-only — output always uses the canonical name — which makes them the
* tool for accepting a renamed field from older clients without emitting it.
*
* ```ts
* class User {
* @JsonProperty('surname')
* @JsonAlias('last_name', 'lastName')
* surname: string;
* }
* ```
*/
export function JsonAlias(...names: string[]) {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
const existing: string[] = metadataStorage.getOwnMetadata(METADATA_KEYS.ALIASES, target, propertyKey) || [];
metadataStorage.defineMetadata(METADATA_KEYS.ALIASES, [...existing, ...names], target, propertyKey);
};
}
/**
* @JsonIgnore()
* Excludes this property from mapping in both directions.
*/
export function JsonIgnore() {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'none', target, propertyKey);
};
}
/**
* @JsonReadOnly()
* Serialized to JSON, but never populated from incoming JSON.
*
* For server-owned fields — ids, timestamps — that a client must not be able to set.
*/
export function JsonReadOnly() {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'readonly', target, propertyKey);
};
}
/**
* @JsonWriteOnly()
* Populated from incoming JSON, but never serialized back out.
*
* For secrets — passwords, tokens — that you accept but must never echo.
*/
export function JsonWriteOnly() {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'writeonly', target, propertyKey);
};
}
/** /**
* @JsonSerialize(serializer: ClassConstructor<JsonSerializer>) * @JsonSerialize(serializer: ClassConstructor<JsonSerializer>)
* Custom serializer decorator. * Custom serializer decorator.
@@ -98,14 +200,34 @@ export function JsonType(typeFunction: () => ClassConstructor<any>) {
}; };
} }
export interface PolymorphicOptions {
/**
* What to do when the discriminator value matches no registered subtype.
* - `keep` (default): pass the raw value through untouched.
* - `error`: throw a {@link JsonMappingError} naming the unknown discriminator value.
*/
onUnknown?: 'keep' | 'error';
/** Subtype to use when the discriminator matches nothing. Takes precedence over `onUnknown`. */
fallback?: ClassConstructor<any>;
}
/** /**
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) * @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[], options?: PolymorphicOptions)
* Defines polymorphic behavior for a property. * Defines polymorphic behavior for a property.
*/ */
export function JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) { export function JsonPolymorphic(
discriminator: string,
subTypes: { value: ClassConstructor<any>, name: string }[],
options?: PolymorphicOptions
) {
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey); registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.POLYMORPHIC, { discriminator, subTypes }, target, propertyKey); metadataStorage.defineMetadata(
METADATA_KEYS.POLYMORPHIC,
{ discriminator, subTypes, onUnknown: options?.onUnknown ?? 'keep', fallback: options?.fallback },
target,
propertyKey
);
}; };
} }
@@ -333,10 +455,16 @@ export function IsUrl(options?: ValidationOptions) {
* @Matches(pattern: RegExp) * @Matches(pattern: RegExp)
*/ */
export function Matches(pattern: RegExp, options?: ValidationOptions) { export function Matches(pattern: RegExp, options?: ValidationOptions) {
// A `g` or `y` flag makes RegExp.prototype.test stateful: it advances lastIndex on a
// match and resumes from there on the next call, so validating the same value twice
// yields different answers. Validation must be a pure predicate, so drop those flags.
const stateless = pattern.flags.includes('g') || pattern.flags.includes('y')
? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
: pattern;
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, { addValidation(target, propertyKey, {
name: 'matches', name: 'matches',
validate: (v) => typeof v === 'string' && pattern.test(v), validate: (v) => typeof v === 'string' && stateless.test(v),
message: `${propertyKey} must match ${pattern} regular expression`, message: `${propertyKey} must match ${pattern} regular expression`,
constraints: [pattern] constraints: [pattern]
}, options); }, options);
@@ -438,14 +566,504 @@ export function IsDate(options?: ValidationOptions) {
}; };
} }
// --- Equality and presence ---
/** /**
* @ValidateNested() * @Equals(comparison: any)
*/ */
export function ValidateNested() { export function Equals(comparison: any, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'equals',
validate: (v) => v === comparison,
message: `${propertyKey} must be equal to ${JSON.stringify(comparison)}`,
constraints: [comparison]
}, options);
};
}
/**
* @NotEquals(comparison: any)
*/
export function NotEquals(comparison: any, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'notEquals',
validate: (v) => v !== comparison,
message: `${propertyKey} must not be equal to ${JSON.stringify(comparison)}`,
constraints: [comparison]
}, options);
};
}
/**
* @IsEmpty()
* Passes for null, undefined, '', [] and {} — the mirror of `@IsNotEmpty`.
*/
export function IsEmpty(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isEmpty',
validate: (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;
return false;
},
message: `${propertyKey} must be empty`
}, options);
};
}
/**
* @IsEnum(entity: object)
* Checks the value is a member of a TypeScript enum (string or numeric).
*/
export function IsEnum(entity: Record<string, any>, options?: ValidationOptions) {
// A numeric enum compiles to a two-way map ({ A: 0, '0': 'A' }), so the reverse-mapped
// names have to be filtered out or 'A' would validate as a legal value.
const values = Object.keys(entity)
.filter(key => typeof entity[entity[key]] !== 'number')
.map(key => entity[key]);
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isEnum',
validate: (v) => values.includes(v),
message: `${propertyKey} must be one of the following values: ${values.join(', ')}`,
constraints: [values]
}, options);
};
}
/**
* @IsInstance(target: ClassConstructor)
*/
export function IsInstance(clazz: ClassConstructor<any>, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isInstance',
validate: (v) => v instanceof clazz,
message: `${propertyKey} must be an instance of ${clazz.name}`,
constraints: [clazz]
}, options);
};
}
// --- Strings ---
/**
* @Length(min: number, max?: number)
*/
export function Length(min: number, max?: number, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'length',
validate: (v) => typeof v === 'string' && v.length >= min && (max === undefined || v.length <= max),
message: max === undefined
? `${propertyKey} must be at least ${min} characters`
: `${propertyKey} must be between ${min} and ${max} characters`,
constraints: max === undefined ? [min] : [min, max]
}, options);
};
}
function stringPattern(name: string, regex: RegExp, describe: (property: string) => string) {
return (options?: ValidationOptions) => (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name,
validate: (v) => typeof v === 'string' && regex.test(v),
message: describe(propertyKey)
}, options);
};
}
/** @IsAlpha() — letters only. */
export const IsAlpha = stringPattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
/** @IsAlphanumeric() — letters and digits only. */
export const IsAlphanumeric = stringPattern(
'isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`
);
/** @IsNumberString() — a string that parses as a finite number. */
export function IsNumberString(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isNumberString',
validate: (v) => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)),
message: `${propertyKey} must be a number string`
}, options);
};
}
/** @IsLowercase() */
export function IsLowercase(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isLowercase',
validate: (v) => typeof v === 'string' && v === v.toLowerCase(),
message: `${propertyKey} must be lowercase`
}, options);
};
}
/** @IsUppercase() */
export function IsUppercase(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isUppercase',
validate: (v) => typeof v === 'string' && v === v.toUpperCase(),
message: `${propertyKey} must be uppercase`
}, options);
};
}
/** @Contains(seed: string) */
export function Contains(seed: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'contains',
validate: (v) => typeof v === 'string' && v.includes(seed),
message: `${propertyKey} must contain ${JSON.stringify(seed)}`,
constraints: [seed]
}, options);
};
}
/** @NotContains(seed: string) */
export function NotContains(seed: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'notContains',
validate: (v) => typeof v === 'string' && !v.includes(seed),
message: `${propertyKey} must not contain ${JSON.stringify(seed)}`,
constraints: [seed]
}, options);
};
}
/** @StartsWith(prefix: string) */
export function StartsWith(prefix: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'startsWith',
validate: (v) => typeof v === 'string' && v.startsWith(prefix),
message: `${propertyKey} must start with ${JSON.stringify(prefix)}`,
constraints: [prefix]
}, options);
};
}
/** @EndsWith(suffix: string) */
export function EndsWith(suffix: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'endsWith',
validate: (v) => typeof v === 'string' && v.endsWith(suffix),
message: `${propertyKey} must end with ${JSON.stringify(suffix)}`,
constraints: [suffix]
}, options);
};
}
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
const MAX_UUID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
/**
* @IsUUID(version?: 1|2|3|4|5|6|7|8)
* Without a version, accepts any RFC 9562 UUID plus the nil and max UUIDs.
*/
export function IsUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, options?: ValidationOptions) {
const pattern = version
? new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, 'i')
: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isUuid',
validate: (v) => {
if (typeof v !== 'string') return false;
if (!version && (v.toLowerCase() === NIL_UUID || v.toLowerCase() === MAX_UUID)) return true;
return pattern.test(v);
},
message: `${propertyKey} must be a valid UUID${version ? ` (version ${version})` : ''}`,
...(version ? { constraints: [version] } : {})
}, options);
};
}
/** @IsJSON() — a string that JSON.parse accepts. */
export function IsJSON(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isJson',
validate: (v) => {
if (typeof v !== 'string') return false;
try {
JSON.parse(v);
return true;
} catch {
return false;
}
},
message: `${propertyKey} must be a JSON string`
}, options);
};
}
/** @IsDateString() — an ISO-8601 string that parses to a real date. */
export function IsDateString(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isDateString',
validate: (v) => typeof v === 'string' && !isNaN(Date.parse(v)),
message: `${propertyKey} must be a valid ISO 8601 date string`
}, options);
};
}
/** @IsSemVer() */
export const IsSemVer = stringPattern(
'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`
);
/** @IsHexColor() — #rgb, #rrggbb or #rrggbbaa. */
export const IsHexColor = stringPattern(
'isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`
);
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
/**
* @IsIP(version?: 4 | 6)
*/
export function IsIP(version?: 4 | 6, options?: ValidationOptions) {
const isV6 = (v: string) => {
// Node's URL parser is the most reliable IPv6 validator available without a dependency.
try {
return new URL(`http://[${v}]`).hostname === `[${v.toLowerCase()}]` || /^[0-9a-f:.]+$/i.test(v) && v.includes(':');
} catch {
return false;
}
};
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isIp',
validate: (v) => {
if (typeof v !== 'string') return false;
if (version === 4) return IPV4.test(v);
if (version === 6) return isV6(v);
return IPV4.test(v) || isV6(v);
},
message: `${propertyKey} must be a valid IP${version ? `v${version}` : ''} address`,
...(version ? { constraints: [version] } : {})
}, options);
};
}
// --- Numbers ---
/** @IsDivisibleBy(divisor: number) */
export function IsDivisibleBy(divisor: number, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isDivisibleBy',
validate: (v) => typeof v === 'number' && Number.isFinite(v) && divisor !== 0 && v % divisor === 0,
message: `${propertyKey} must be divisible by ${divisor}`,
constraints: [divisor]
}, options);
};
}
/** @IsPort() — an integer in 0..65535, as a number or a numeric string. */
export function IsPort(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isPort',
validate: (v) => {
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
},
message: `${propertyKey} must be a valid port number`
}, options);
};
}
/** @IsLatitude() */
export function IsLatitude(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isLatitude',
validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90,
message: `${propertyKey} must be a latitude between -90 and 90`
}, options);
};
}
/** @IsLongitude() */
export function IsLongitude(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isLongitude',
validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180,
message: `${propertyKey} must be a longitude between -180 and 180`
}, options);
};
}
/** @IsBigInt() */
export function IsBigInt(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isBigInt',
validate: (v) => typeof v === 'bigint',
message: `${propertyKey} must be a bigint`
}, options);
};
}
// --- Dates ---
type DateBound = Date | (() => Date);
const boundOf = (bound: DateBound): Date => (typeof bound === 'function' ? bound() : bound);
/**
* @MinDate(date: Date | (() => Date))
* Accepts a thunk so a moving boundary — "not in the past" — is evaluated per validation
* rather than frozen when the class was declared.
*/
export function MinDate(min: DateBound, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'minDate',
validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() >= boundOf(min).getTime(),
message: (args) => `${args.property} must not be earlier than ${boundOf(min).toISOString()}`,
constraints: [min]
}, options);
};
}
/**
* @MaxDate(date: Date | (() => Date))
*/
export function MaxDate(max: DateBound, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'maxDate',
validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() <= boundOf(max).getTime(),
message: (args) => `${args.property} must not be later than ${boundOf(max).toISOString()}`,
constraints: [max]
}, options);
};
}
// --- Arrays ---
/**
* @ArrayUnique(identifier?: (item: any) => any)
* Pass an extractor to deduplicate objects by a key rather than by reference.
*/
export function ArrayUnique(identifier?: (item: any) => any, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'arrayUnique',
validate: (v) => {
if (!Array.isArray(v)) return false;
const keys = identifier ? v.map(identifier) : v;
return new Set(keys).size === keys.length;
},
message: `${propertyKey} must not contain duplicate values`
}, options);
};
}
/** @ArrayContains(values: any[]) — the array must contain every listed value. */
export function ArrayContains(values: any[], options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'arrayContains',
validate: (v) => Array.isArray(v) && values.every(value => v.includes(value)),
message: `${propertyKey} must contain the following values: ${values.join(', ')}`,
constraints: [values]
}, options);
};
}
/** @ArrayNotContains(values: any[]) — the array must contain none of the listed values. */
export function ArrayNotContains(values: any[], options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'arrayNotContains',
validate: (v) => Array.isArray(v) && values.every(value => !v.includes(value)),
message: `${propertyKey} must not contain any of the following values: ${values.join(', ')}`,
constraints: [values]
}, options);
};
}
// --- Control flow ---
/**
* @ValidateIf(condition: (object: any) => boolean)
* Skips every constraint on this property when the condition returns false.
*
* ```ts
* class Payment {
* @IsIn(['card', 'invoice'])
* method: string;
*
* @ValidateIf(o => o.method === 'card')
* @IsString()
* cardNumber?: string;
* }
* ```
*/
export function ValidateIf(condition: (object: any) => boolean) {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.CONDITION, condition, target, propertyKey);
};
}
/**
* @Allow()
* Declares a property with no constraints of its own.
*
* Useful with `unknownKeys: 'strip'` or `'error'`, where a property has to be declared to
* survive the payload even though nothing about its value needs checking.
*/
export function Allow() {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
};
}
/**
* @ValidateNested(options?: ValidationOptions)
* Recursively validates the value of this property.
*
* `{ each: true }` documents that the property holds a collection; nested validation
* already recurses into arrays, but passing `each` additionally asserts that the value
* really is an array.
*/
export function ValidateNested(options?: ValidationOptions) {
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey); registerProperty(target, propertyKey);
// This is a marker for recursive validation // This is a marker for recursive validation
metadataStorage.defineMetadata('cereale:nested', true, target, propertyKey); metadataStorage.defineMetadata(METADATA_KEYS.NESTED, true, target, propertyKey);
if (options?.each) {
addValidation(target, propertyKey, {
name: 'nestedEach',
validate: (v) => Array.isArray(v),
message: `${propertyKey} must be an array`
});
}
}; };
} }
+57
View File
@@ -0,0 +1,57 @@
import type { ValidationError } from './utils.js';
/**
* Flattens the nested {@link ValidationError} tree into a flat map of dotted paths to
* messages — the shape you actually want when turning a failure into an HTTP 400 body.
*
* ```ts
* flattenErrors(errors);
* // {
* // "name": ["name must be a string"],
* // "items[0].qty": ["qty must be at least 1"]
* // }
* ```
*/
export function flattenErrors(errors: ValidationError[]): Record<string, string[]> {
const flat: Record<string, string[]> = {};
const walk = (nodes: ValidationError[], prefix: string) => {
for (const node of nodes) {
// Array indices read as `items[0]`, named properties as `order.total`.
const path = node.property.startsWith('[')
? `${prefix}${node.property}`
: prefix ? `${prefix}.${node.property}` : node.property;
const messages = Object.values(node.constraints);
if (messages.length > 0) {
(flat[path] ??= []).push(...messages);
}
if (node.children?.length) {
walk(node.children, path);
}
}
};
walk(errors, '');
return flat;
}
/**
* Renders the error tree as human-readable lines, one per failed rule.
*
* Intended for logs and CLI output; use {@link flattenErrors} when the destination is JSON.
*/
export function formatErrors(errors: ValidationError[]): string {
const flat = flattenErrors(errors);
return Object.entries(flat)
.flatMap(([path, messages]) => messages.map(message => `${path}: ${message}`))
.join('\n');
}
/**
* Collects every message in the tree, discarding paths.
*/
export function collectErrorMessages(errors: ValidationError[]): string[] {
return Object.values(flattenErrors(errors)).flat();
}
+105 -53
View File
@@ -5,11 +5,21 @@ import {
ValidateNested, ValidateNested,
IsArray, IsArray,
IsDate, IsDate,
IsEnum,
IsUUID,
ValidateIf,
JsonProperty,
JsonAlias,
JsonReadOnly,
JsonWriteOnly,
JsonSerialize, JsonSerialize,
JsonDeserialize, JsonDeserialize,
JsonPolymorphic, JsonPolymorphic,
toJson, toJson,
fromJson, fromJson,
toPlain,
validate,
flattenErrors,
JsonSerializer, JsonSerializer,
JsonDeserializer, JsonDeserializer,
Validate, Validate,
@@ -32,14 +42,14 @@ class IsLongerThan implements ValidatorConstraintInterface {
} }
} }
function IsUsername(options?: ValidationOptions) { function IsSlug(options?: ValidationOptions) {
return function (object: any, propertyName: string) { return function (object: any, propertyName: string) {
registerDecorator({ registerDecorator({
name: 'isUsername', name: 'isSlug',
target: object.constructor, target: object.constructor,
propertyName: propertyName, propertyName: propertyName,
...(options ? { options } : {}), ...(options ? { options } : {}),
validator: (value: any) => typeof value === 'string' && /^[a-zA-Z0-9_]+$/.test(value) validator: (value: any) => typeof value === 'string' && /^[a-z0-9-]+$/.test(value)
}); });
}; };
} }
@@ -48,11 +58,8 @@ function IsUsername(options?: ValidationOptions) {
class DateSerializer implements JsonSerializer<Date, string> { class DateSerializer implements JsonSerializer<Date, string> {
serialize(value: Date): string { serialize(value: Date): string {
if (value instanceof Date) {
return value.toISOString().split('T')[0] || ''; return value.toISOString().split('T')[0] || '';
} }
return String(value);
}
} }
class DateDeserializer implements JsonDeserializer<string, Date> { class DateDeserializer implements JsonDeserializer<string, Date> {
@@ -63,10 +70,16 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
// --- Domain Models --- // --- Domain Models ---
enum Format {
Hardback = 'hardback',
Paperback = 'paperback',
}
abstract class Media { abstract class Media {
@IsString() @IsString()
abstract type: string; abstract type: string;
// Declared once here. Subclasses inherit the rule without restating it.
@IsString() @IsString()
title: string; title: string;
} }
@@ -75,14 +88,14 @@ class Book extends Media {
@IsString() @IsString()
override type: string = 'book'; override type: string = 'book';
@IsString()
@IsUsername({ message: 'Title must be a valid alphanumeric username' })
declare title: string;
@IsString() @IsString()
@Validate(IsLongerThan, [5]) @Validate(IsLongerThan, [5])
author: string; author: string;
@IsEnum(Format)
format: Format = Format.Paperback;
@JsonProperty('published_at')
@JsonSerialize(DateSerializer) @JsonSerialize(DateSerializer)
@JsonDeserialize(DateDeserializer) @JsonDeserialize(DateDeserializer)
@IsDate() @IsDate()
@@ -91,19 +104,38 @@ class Book extends Media {
class Movie extends Media { class Movie extends Media {
@IsString() @IsString()
type: string = 'movie'; override type: string = 'movie';
@IsInt() @IsInt()
@Min(1) @Min(1)
duration: number; duration: number;
// Only checked for films that claim to be part of a series.
@ValidateIf((movie: Movie) => movie.duration > 200)
@IsString()
intermissionNote?: string;
} }
class Library { class Library {
@JsonReadOnly()
@IsUUID(4)
id: string;
@IsString() @IsString()
@IsSlug({ message: 'name must be a lowercase slug' })
name: string; name: string;
@JsonProperty('curator_email')
@JsonAlias('curatorEmail')
@IsString()
curatorEmail: string;
@JsonWriteOnly()
@IsString()
adminToken: string;
@IsArray() @IsArray()
@ValidateNested() @ValidateNested({ each: true })
@JsonPolymorphic('type', [ @JsonPolymorphic('type', [
{ value: Book, name: 'book' }, { value: Book, name: 'book' },
{ value: Movie, name: 'movie' } { value: Movie, name: 'movie' }
@@ -114,64 +146,84 @@ class Library {
// --- Execution --- // --- Execution ---
async function runExample() { async function runExample() {
console.log("--- Starting Example ---"); console.log('--- Starting Example ---');
// 1. Create a Library instance
const library = new Library(); const library = new Library();
library.name = "Central Library"; library.id = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
library.name = 'central-library';
library.curatorEmail = 'ada@example.com';
library.adminToken = 'super-secret';
const book = new Book(); const book = new Book();
book.title = "Gatsby"; book.title = 'Gatsby';
book.author = "Fitzgerald"; book.author = 'Fitzgerald';
book.publishedAt = new Date("1925-04-10"); book.format = Format.Hardback;
book.publishedAt = new Date('1925-04-10');
const movie = new Movie(); const movie = new Movie();
movie.title = "Inception"; movie.title = 'Inception';
movie.duration = 148; movie.duration = 148;
library.items = [book, movie]; library.items = [book, movie];
try { // 1. Serialize, honouring @JsonProperty and the write-only token
// 2. Serialize to JSON console.log('\n[1] Serializing Library to JSON...');
console.log("\n[1] Serializing Library to JSON...");
const json = await toJson(library); const json = await toJson(library);
console.log("JSON Output:", json); console.log('JSON Output:', json);
console.log('Secret withheld from output:', !json.includes('super-secret'));
// 3. Deserialize back to Instance // 2. Deserialize back, resolving the polymorphic items
console.log("\n[2] Deserializing JSON back to Library instance..."); console.log('\n[2] Deserializing JSON back to Library instance...');
const deserializedLibrary = await fromJson(Library, json); const restored = await fromJson(Library, json, { validate: false });
console.log("Deserialized Library Name:", deserializedLibrary.name); console.log('Curator (read via curator_email):', restored.curatorEmail);
console.log("Items count:", deserializedLibrary.items.length); console.log('Items count:', restored.items.length);
restored.items.forEach((item, index) => {
// Check Polymorphism console.log(`Item ${index} is a ${item.constructor.name}: ${item.title}`);
deserializedLibrary.items.forEach((item, index) => {
console.log(`Item ${index} is instance of ${item.constructor.name}: ${item.title}`);
if (item instanceof Book) { if (item instanceof Book) {
console.log(` > Book Author: ${item.author}`); console.log(` > Author: ${item.author}, format: ${item.format}`);
console.log(` > Published At: ${item.publishedAt.toISOString()} (instanceof Date: ${item.publishedAt instanceof Date})`); console.log(` > Published: ${item.publishedAt.toISOString()} (Date: ${item.publishedAt instanceof Date})`);
} else if (item instanceof Movie) { } else if (item instanceof Movie) {
console.log(` > Movie Duration: ${item.duration} mins`); console.log(` > Duration: ${item.duration} mins`);
} }
}); });
// 4. Test Validation Failure // 3. A client cannot set a @JsonReadOnly field
console.log("\n[3] Testing Validation Failure (Invalid Movie Duration)..."); console.log('\n[3] A client trying to set the read-only id...');
const invalidJson = JSON.stringify({ const hijacked = await fromJson(
name: "Invalid Library", Library,
items: [ JSON.stringify({ id: 'attacker-supplied', name: 'x', curator_email: 'a@b.c', adminToken: 't', items: [] }),
{ type: "movie", title: "Short Film", duration: -5 } // Invalid: duration < 1 { validate: false }
] );
}); console.log('id after mapping (expected undefined):', hijacked.id);
await fromJson(Library, invalidJson); // 4. Validation failures, flattened for an HTTP response
} catch (error) { console.log('\n[4] Reporting validation failures...');
if (error instanceof Error) { const invalid = await fromJson(
console.log("Caught expected error:", error.message); Library,
if ((error as any).errors) { JSON.stringify({
console.log("Validation details:", JSON.stringify((error as any).errors, null, 2)); name: 'Not A Slug',
} curator_email: 'a@b.c',
} adminToken: 't',
} items: [{ type: 'movie', title: 'Short Film', duration: -5 }]
}),
{ validate: false }
);
console.log(flattenErrors(await validate(invalid)));
// 5. A base-class rule applies to a subclass that never restates it
console.log('\n[5] Base-class constraints reach subclasses...');
const untitled = new Book();
untitled.title = undefined as any;
untitled.author = 'Fitzgerald';
untitled.publishedAt = new Date('1925-04-10');
console.log(flattenErrors(await validate(untitled)));
// 6. Naming strategies convert every property at once
console.log('\n[6] The same movie under snake_case...');
console.log(await toPlain(movie, { namingStrategy: 'snake_case' }));
} }
runExample(); runExample().catch((error) => {
console.error('Example failed:', error);
process.exitCode = 1;
});
+222
View File
@@ -0,0 +1,222 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
JsonSerializer, JsonDeserializer, JsonMappingError,
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
} from './index.js';
afterEach(() => resetConfig());
describe('plan caching', () => {
// The validation plan for a class is memoized. It must not go stale when metadata is
// registered after the class has already been validated once.
it('picks up a decorator registered after the first validation', async () => {
class Late {
value: any;
}
const before = new Late();
before.value = 'anything';
expect(await validate(before)).toEqual([]);
// Register a rule after the plan has already been built and cached.
registerDecorator({
name: 'isEven',
target: Late,
propertyName: 'value',
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
});
const after = new Late();
after.value = 'anything';
expect(await validate(after)).toHaveLength(1);
after.value = 4;
expect(await validate(after)).toEqual([]);
});
it('keeps per-class plans separate', async () => {
class A {
@IsString()
v: any;
}
class B {
@IsInt()
v: any;
}
const a = new A();
a.v = 'text';
const b = new B();
b.v = 'text';
expect(await validate(a)).toEqual([]);
expect(await validate(b)).toHaveLength(1);
});
it('reuses one serializer instance rather than constructing per property', async () => {
let constructed = 0;
class Counting implements JsonSerializer<string, string> {
constructor() { constructed++; }
serialize(value: string): string { return value.toUpperCase(); }
}
class Doc {
@JsonSerialize(Counting)
a: string;
@JsonSerialize(Counting)
b: string;
}
const doc = new Doc();
doc.a = 'x';
doc.b = 'y';
await toPlain(doc);
await toPlain(doc);
await toPlain(doc);
expect(await toPlain(doc)).toEqual({ a: 'X', b: 'Y' });
expect(constructed).toBe(1);
});
it('still honours a deserializer after caching', async () => {
class ToDate implements JsonDeserializer<string, Date> {
deserialize(value: string): Date { return new Date(value); }
}
class Event {
@JsonDeserialize(ToDate)
at: Date;
}
for (let i = 0; i < 3; i++) {
const e = await toInstance(Event, { at: '2026-01-01T00:00:00Z' });
expect(e.at).toBeInstanceOf(Date);
}
});
});
describe('maxDepth guard', () => {
const nest = (depth: number): any => {
let node: any = { value: 'leaf' };
for (let i = 0; i < depth; i++) node = { child: node };
return node;
};
class Node {
@ValidateNested()
@JsonType(() => Node)
child?: Node;
value?: string;
}
it('rejects a payload nested past the limit instead of exhausting the stack', async () => {
await expect(toInstance(Node, nest(500), { validate: false }))
.rejects.toThrow(JsonMappingError);
await expect(toInstance(Node, nest(500), { validate: false }))
.rejects.toThrow(/Maximum nesting depth/);
});
it('accepts nesting within the limit', async () => {
const parsed = await toInstance(Node, nest(10), { validate: false });
expect(parsed).toBeInstanceOf(Node);
});
it('is configurable per call and globally', async () => {
await expect(toInstance(Node, nest(10), { validate: false, maxDepth: 3 }))
.rejects.toThrow(/Maximum nesting depth of 3/);
configure({ maxDepth: 2 });
await expect(toInstance(Node, nest(10), { validate: false }))
.rejects.toThrow(/Maximum nesting depth of 2/);
});
it('guards serialization too', async () => {
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
await expect(toPlain(deep, { validate: false, maxDepth: 5 }))
.rejects.toThrow(/Maximum nesting depth/);
});
it('guards validation too', async () => {
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
await expect(validate(deep, { maxDepth: 5 })).rejects.toThrow(/Maximum nesting depth/);
});
});
describe('each: true error reporting', () => {
it('names the index of the element that failed', async () => {
class Basket {
@IsIn(['a', 'b'], { each: true })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
const errors = await validate(basket);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints['isIn']).toContain('failed at index 3');
});
it('leaves a caller-supplied message untouched', async () => {
class Basket {
@IsIn(['a'], { each: true, message: 'bad tag' })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'zzz'];
const errors = await validate(basket);
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
});
it('gives the failing element to a message function, not the whole array', async () => {
class Basket {
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'zzz'];
const errors = await validate(basket);
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
});
it('reports nothing when every element passes', async () => {
class Basket {
@IsIn(['a', 'b'], { each: true })
tags: string[];
}
const basket = new Basket();
basket.tags = ['a', 'b'];
expect(await validate(basket)).toEqual([]);
});
});
describe('validate() accepts options', () => {
it('threads maxDepth through nested validation', async () => {
class Item {
@IsInt()
@Min(1)
qty: number;
}
class Order {
@IsString()
ref: string;
@ValidateNested()
@JsonType(() => Item)
items: Item[];
}
const bad = new Item();
bad.qty = -1;
const order = new Order();
order.ref = 'r';
order.items = [bad];
// Deep enough to be fine at the default, so behaviour is unchanged.
expect(await validate(order)).toHaveLength(1);
});
});
+9 -9
View File
@@ -202,8 +202,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('username'); expect(errors[0]!.property).toBe('username');
expect(errors[0].constraints).toHaveProperty('minLength'); expect(errors[0]!.constraints).toHaveProperty('minLength');
}); });
it('should fail on invalid email', async () => { it('should fail on invalid email', async () => {
@@ -215,8 +215,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('email'); expect(errors[0]!.property).toBe('email');
expect(errors[0].constraints).toHaveProperty('isEmail'); expect(errors[0]!.constraints).toHaveProperty('isEmail');
}); });
it('should fail on invalid role (IsIn)', async () => { it('should fail on invalid role (IsIn)', async () => {
@@ -228,8 +228,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('roles'); expect(errors[0]!.property).toBe('roles');
expect(errors[0].constraints).toHaveProperty('isIn'); expect(errors[0]!.constraints).toHaveProperty('isIn');
}); });
it('should skip validation for null optional field', async () => { it('should skip validation for null optional field', async () => {
@@ -238,7 +238,7 @@ describe('JsonMapper', () => {
user.email = 'john@example.com'; user.email = 'john@example.com';
user.active = true; user.active = true;
user.roles = ['user']; user.roles = ['user'];
user.age = undefined; // optional delete user.age; // optional
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
@@ -254,8 +254,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('age'); expect(errors[0]!.property).toBe('age');
expect(errors[0].constraints).toHaveProperty('min'); expect(errors[0]!.constraints).toHaveProperty('min');
}); });
}); });
}); });
+3
View File
@@ -1,3 +1,6 @@
export * from './interfaces.js'; export * from './interfaces.js';
export * from './naming.js';
export * from './config.js';
export * from './decorators.js'; export * from './decorators.js';
export * from './errors.js';
export * from './utils.js'; export * from './utils.js';
+449
View File
@@ -0,0 +1,449 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
IsString, IsInt, IsOptional, ValidateNested, Min,
JsonProperty, JsonAlias, JsonIgnore, JsonReadOnly, JsonWriteOnly, JsonType,
JsonMappingError, JsonValidationError,
toPlain, toJson, toInstance, fromJson, validate, validateOrReject,
configure, resetConfig, getConfig,
flattenErrors, formatErrors, collectErrorMessages,
resolveNamingStrategy,
} from './index.js';
afterEach(() => resetConfig());
describe('@JsonProperty', () => {
class User {
@JsonProperty('first_name')
@IsString()
firstName: string;
@JsonProperty('last_name')
@IsString()
lastName: string;
}
it('renames on the way out', async () => {
const u = new User();
u.firstName = 'Ada';
u.lastName = 'Lovelace';
await expect(toPlain(u)).resolves.toEqual({ first_name: 'Ada', last_name: 'Lovelace' });
});
it('renames on the way in', async () => {
const u = await fromJson(User, '{"first_name":"Ada","last_name":"Lovelace"}');
expect(u.firstName).toBe('Ada');
expect(u.lastName).toBe('Lovelace');
});
it('round-trips', async () => {
const json = '{"first_name":"Ada","last_name":"Lovelace"}';
expect(await toJson(await fromJson(User, json))).toBe(json);
});
it('no longer accepts the raw property name once renamed', async () => {
const u = await toInstance(
User,
{ firstName: 'Ada', last_name: 'L' },
{ unknownKeys: 'strip', validate: false }
);
expect(u.firstName).toBeUndefined();
expect(u.lastName).toBe('L');
});
it('rejects two properties claiming the same JSON name', async () => {
class Clash {
@JsonProperty('name')
a: string;
@JsonProperty('name')
b: string;
}
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(JsonMappingError);
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(/both map to the JSON name/);
});
});
describe('@JsonAlias', () => {
class Person {
@JsonProperty('surname')
@JsonAlias('last_name', 'lastName')
@IsString()
surname: string;
}
it('accepts every alias on input', async () => {
for (const key of ['surname', 'last_name', 'lastName']) {
const p = await toInstance(Person, { [key]: 'Hopper' });
expect(p.surname).toBe('Hopper');
}
});
it('never emits an alias on output', async () => {
const p = new Person();
p.surname = 'Hopper';
await expect(toPlain(p)).resolves.toEqual({ surname: 'Hopper' });
});
});
describe('access control decorators', () => {
it('@JsonIgnore drops the property in both directions', async () => {
class Secretive {
@IsString()
name: string;
@JsonIgnore()
internalNote: string;
}
const s = new Secretive();
s.name = 'x';
s.internalNote = 'do not leak';
await expect(toPlain(s)).resolves.toEqual({ name: 'x' });
const parsed = await toInstance(Secretive, { name: 'x', internalNote: 'injected' });
expect(parsed.internalNote).toBeUndefined();
});
it('@JsonWriteOnly accepts input but never echoes it back', async () => {
class Credentials {
@IsString()
email: string;
@JsonWriteOnly()
@IsString()
password: string;
}
const c = await toInstance(Credentials, { email: 'a@b.com', password: 'hunter2' });
expect(c.password).toBe('hunter2');
await expect(toPlain(c)).resolves.toEqual({ email: 'a@b.com' });
});
it('@JsonReadOnly is emitted but cannot be set by a client', async () => {
class Record {
@JsonReadOnly()
id: number;
@IsString()
title: string;
}
const r = await toInstance(Record, { id: 999, title: 'hello' });
expect(r.id).toBeUndefined();
r.id = 1;
await expect(toPlain(r)).resolves.toEqual({ id: 1, title: 'hello' });
});
it('@JsonReadOnly is not resurrected by the default unknownKeys policy', async () => {
class Record {
@JsonProperty('identifier')
@JsonReadOnly()
id: number;
@IsString()
title: string;
}
const r = await toInstance(Record, { identifier: 999, title: 't' }, { unknownKeys: 'allow' });
expect(r.id).toBeUndefined();
expect((r as any).identifier).toBeUndefined();
});
});
describe('naming strategies', () => {
class Account {
@IsString()
accountHolderName: string;
@IsInt()
balanceInCents: number;
}
it('snake_case both ways', async () => {
const a = new Account();
a.accountHolderName = 'Ada';
a.balanceInCents = 100;
const plain = await toPlain(a, { namingStrategy: 'snake_case' });
expect(plain).toEqual({ account_holder_name: 'Ada', balance_in_cents: 100 });
const back = await toInstance(Account, plain, { namingStrategy: 'snake_case' });
expect(back.accountHolderName).toBe('Ada');
expect(back.balanceInCents).toBe(100);
});
it('kebab-case, SCREAMING_SNAKE_CASE and PascalCase', async () => {
const a = new Account();
a.accountHolderName = 'Ada';
a.balanceInCents = 1;
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
.resolves.toEqual({ 'account-holder-name': 'Ada', 'balance-in-cents': 1 });
await expect(toPlain(a, { namingStrategy: 'SCREAMING_SNAKE_CASE' }))
.resolves.toEqual({ ACCOUNT_HOLDER_NAME: 'Ada', BALANCE_IN_CENTS: 1 });
await expect(toPlain(a, { namingStrategy: 'PascalCase' }))
.resolves.toEqual({ AccountHolderName: 'Ada', BalanceInCents: 1 });
});
it('accepts a custom function', async () => {
const a = new Account();
a.accountHolderName = 'Ada';
a.balanceInCents = 1;
await expect(toPlain(a, { namingStrategy: (k) => `x_${k}` }))
.resolves.toEqual({ x_accountHolderName: 'Ada', x_balanceInCents: 1 });
});
it('@JsonProperty wins over the naming strategy', async () => {
class Mixed {
@JsonProperty('EXPLICIT')
someField: string;
otherField: string;
}
const m = new Mixed();
m.someField = 'a';
m.otherField = 'b';
await expect(toPlain(m, { namingStrategy: 'snake_case' }))
.resolves.toEqual({ EXPLICIT: 'a', other_field: 'b' });
});
it('splits acronyms the way a reader expects', () => {
const snake = resolveNamingStrategy('snake_case');
expect(snake('parseHTTPResponse')).toBe('parse_http_response');
expect(snake('firstName')).toBe('first_name');
expect(snake('id')).toBe('id');
expect(snake('already_snake')).toBe('already_snake');
const camel = resolveNamingStrategy('camelCase');
expect(camel('first_name')).toBe('firstName');
});
it('rejects an unknown strategy name', () => {
expect(() => resolveNamingStrategy('shouty' as any)).toThrow(/Unknown naming strategy/);
});
it('applies to nested objects too', async () => {
class Inner {
@IsString()
innerValue: string;
}
class Outer {
@ValidateNested()
@JsonType(() => Inner)
outerChild: Inner;
}
const parsed = await toInstance(
Outer,
{ outer_child: { inner_value: 'v' } },
{ namingStrategy: 'snake_case' }
);
expect(parsed.outerChild).toBeInstanceOf(Inner);
expect(parsed.outerChild.innerValue).toBe('v');
});
});
describe('configure()', () => {
class Account {
@IsString()
accountHolderName: string;
}
it('sets a library-wide default', async () => {
configure({ namingStrategy: 'snake_case' });
const a = new Account();
a.accountHolderName = 'Ada';
await expect(toPlain(a)).resolves.toEqual({ account_holder_name: 'Ada' });
expect(getConfig().namingStrategy).toBe('snake_case');
});
it('is overridden by per-call options', async () => {
configure({ namingStrategy: 'snake_case' });
const a = new Account();
a.accountHolderName = 'Ada';
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
.resolves.toEqual({ 'account-holder-name': 'Ada' });
});
it('resetConfig() restores the defaults', async () => {
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
resetConfig();
expect(getConfig()).toEqual({
namingStrategy: 'identity',
unknownKeys: 'allow',
validate: true,
maxDepth: 64,
});
});
});
describe('unknownKeys policy', () => {
class Dto {
@IsString()
known: string;
}
it('allow (default) copies unknown keys through', async () => {
const d = await toInstance(Dto, { known: 'a', extra: 'b' });
expect((d as any).extra).toBe('b');
});
it('strip drops them', async () => {
const d = await toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'strip' });
expect((d as any).extra).toBeUndefined();
expect(d.known).toBe('a');
});
it('error rejects the payload and names the offending key', async () => {
await expect(toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'error' }))
.rejects.toThrow(/Unknown property "extra"/);
});
it('never lets __proto__ through, whatever the policy', async () => {
for (const unknownKeys of ['allow', 'strip', 'error'] as const) {
const d = await toInstance(Dto, JSON.parse('{"known":"a","__proto__":{"x":1}}'), { unknownKeys });
expect(Object.getPrototypeOf(d)).toBe(Dto.prototype);
expect(({} as any).x).toBeUndefined();
}
});
});
describe('validate option', () => {
class Strict {
@IsString()
name: string;
@IsOptional()
@IsInt()
@Min(0)
age?: number;
}
it('throws by default', async () => {
await expect(toInstance(Strict, { name: 123 })).rejects.toThrow(JsonValidationError);
});
it('maps without validating when told to', async () => {
const s = await toInstance(Strict, { name: 123 }, { validate: false });
expect(s).toBeInstanceOf(Strict);
expect(s.name).toBe(123 as any);
});
it('skips validation on the way out too', async () => {
const s = new Strict();
s.name = 123 as any;
await expect(toPlain(s, { validate: false })).resolves.toEqual({ name: 123 });
});
it('validateOrReject throws, validate returns', async () => {
const s = new Strict();
s.name = 123 as any;
await expect(validateOrReject(s)).rejects.toThrow(JsonValidationError);
await expect(validate(s)).resolves.toHaveLength(1);
});
});
describe('error helpers', () => {
class Item {
@IsInt()
@Min(1)
qty: number;
}
class Order {
@IsString()
reference: string;
@ValidateNested()
@JsonType(() => Item)
items: Item[];
}
const buildFailing = () => {
const bad = new Item();
bad.qty = -5;
const o = new Order();
o.reference = 42 as any;
o.items = [bad];
return o;
};
it('flattenErrors produces dotted paths with array indices', async () => {
const errors = await validate(buildFailing());
const flat = flattenErrors(errors);
expect(flat['reference']).toEqual(['reference must be a string']);
expect(flat['items[0].qty']).toEqual(['qty must be at least 1']);
});
it('formatErrors renders one line per failure', async () => {
const errors = await validate(buildFailing());
const text = formatErrors(errors);
expect(text).toContain('reference: reference must be a string');
expect(text).toContain('items[0].qty: qty must be at least 1');
});
it('collectErrorMessages returns just the messages', async () => {
const messages = collectErrorMessages(await validate(buildFailing()));
expect(messages).toHaveLength(2);
expect(messages).toContain('qty must be at least 1');
});
it('returns an empty result for a valid object', async () => {
const o = new Order();
o.reference = 'ok';
o.items = [];
expect(flattenErrors(await validate(o))).toEqual({});
expect(formatErrors(await validate(o))).toBe('');
});
});
describe('a realistic API payload', () => {
it('maps a snake_case request and answers without the secret', async () => {
class SignUp {
@JsonReadOnly()
id: number;
@JsonProperty('email_address')
@IsString()
email: string;
@JsonWriteOnly()
@IsString()
password: string;
@IsString()
displayName: string;
}
const body = JSON.stringify({
id: 999, // client must not be able to set this
email_address: 'ada@example.com',
password: 'hunter2',
display_name: 'Ada',
});
const signUp = await fromJson(SignUp, body, { namingStrategy: 'snake_case' });
expect(signUp.id).toBeUndefined();
expect(signUp.email).toBe('ada@example.com');
expect(signUp.password).toBe('hunter2');
expect(signUp.displayName).toBe('Ada');
signUp.id = 1;
const response = await toJson(signUp, { namingStrategy: 'snake_case' });
expect(JSON.parse(response)).toEqual({
id: 1,
email_address: 'ada@example.com',
display_name: 'Ada',
});
expect(response).not.toContain('hunter2');
});
});
+39
View File
@@ -1,6 +1,20 @@
export class MetadataStorage { export class MetadataStorage {
private static instance: MetadataStorage; private static instance: MetadataStorage;
/**
* Bumped whenever any metadata is written.
*
* Decorators run at class-definition time, so in practice this stops changing once the
* application has loaded. Derived structures (see the validation plan cache in utils.ts)
* record the version they were built from and rebuild if it moves, which keeps caching
* safe even for metadata registered late through `registerDecorator`.
*/
private _version = 0;
get version(): number {
return this._version;
}
// Maps a prototype to its property names // Maps a prototype to its property names
private properties = new WeakMap<any, string[]>(); private properties = new WeakMap<any, string[]>();
@@ -24,6 +38,7 @@ export class MetadataStorage {
* Defines metadata for a specific property on a target. * Defines metadata for a specific property on a target.
*/ */
defineMetadata(key: string, value: any, target: any, propertyKey?: string) { defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
this._version++;
if (propertyKey) { if (propertyKey) {
let targetMap = this.propertyMetadata.get(target); let targetMap = this.propertyMetadata.get(target);
if (!targetMap) { if (!targetMap) {
@@ -63,6 +78,29 @@ export class MetadataStorage {
return undefined; return undefined;
} }
/**
* Collects a metadata value from every level of the prototype chain that defines one.
*
* Unlike {@link getMetadata}, which stops at the first (most derived) match, this returns
* every value found, ordered from the BASE class down to the most derived one. It exists
* for metadata that must accumulate across an inheritance chain rather than be overridden —
* validation constraints in particular, where a subclass re-decorating an inherited property
* must add to the base class's rules instead of silently replacing them.
*/
getMetadataChain(key: string, target: any, propertyKey?: string): any[] {
const chain: any[] = [];
let current = target;
while (current) {
const value = this.getOwnMetadata(key, current, propertyKey);
if (value !== undefined) {
// Walking derived -> base, so prepend to end up base-first.
chain.unshift(value);
}
current = Object.getPrototypeOf(current);
}
return chain;
}
/** /**
* Gets metadata defined directly on the target. * Gets metadata defined directly on the target.
*/ */
@@ -78,6 +116,7 @@ export class MetadataStorage {
* Registers a property for a target. * Registers a property for a target.
*/ */
registerProperty(target: any, propertyKey: string) { registerProperty(target: any, propertyKey: string) {
this._version++;
let props = this.properties.get(target); let props = this.properties.get(target);
if (!props) { if (!props) {
props = []; props = [];
+74
View File
@@ -0,0 +1,74 @@
/**
* Translates a class property name into the name used in JSON.
*
* Applied only to properties that do not carry an explicit `@JsonProperty`, which always wins.
*/
export type NamingStrategyFn = (propertyKey: string) => string;
/**
* A built-in strategy name, or your own function.
*
* The built-ins assume property names are written in the TypeScript convention (camelCase)
* and convert away from it.
*/
export type NamingStrategy =
| 'identity'
| 'camelCase'
| 'PascalCase'
| 'snake_case'
| 'SCREAMING_SNAKE_CASE'
| 'kebab-case'
| NamingStrategyFn;
/**
* Splits an identifier into lowercase words.
*
* Handles the two boundaries that matter in practice: a lowercase-or-digit followed by an
* uppercase (`firstName`), and an acronym running into a new word (`parseHTTPResponse`,
* where the split belongs before `Response`, not inside `HTTP`). Existing separators are
* treated as boundaries too, so an already-converted name survives a second pass unchanged.
*/
function words(propertyKey: string): string[] {
return propertyKey
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_\-\s]+/g, ' ')
.trim()
.split(' ')
.filter(Boolean)
.map(word => word.toLowerCase());
}
const capitalize = (word: string): string => (word ? word.charAt(0).toUpperCase() + word.slice(1) : word);
const BUILT_INS: Record<Exclude<NamingStrategy, NamingStrategyFn>, NamingStrategyFn> = {
identity: (key) => key,
camelCase: (key) => {
const parts = words(key);
if (parts.length === 0) return key;
return parts[0] + parts.slice(1).map(capitalize).join('');
},
PascalCase: (key) => words(key).map(capitalize).join('') || key,
snake_case: (key) => words(key).join('_') || key,
SCREAMING_SNAKE_CASE: (key) => words(key).join('_').toUpperCase() || key,
'kebab-case': (key) => words(key).join('-') || key,
};
/**
* Resolves a {@link NamingStrategy} to the function that implements it.
*
* @throws Error when given a name that is not one of the built-in strategies.
*/
export function resolveNamingStrategy(strategy: NamingStrategy | undefined): NamingStrategyFn {
if (!strategy) return BUILT_INS.identity;
if (typeof strategy === 'function') return strategy;
const builtIn = BUILT_INS[strategy];
if (!builtIn) {
throw new Error(
`Unknown naming strategy ${JSON.stringify(strategy)}. ` +
`Use one of: ${Object.keys(BUILT_INS).join(', ')}, or pass your own function.`
);
}
return builtIn;
}
+447
View File
@@ -0,0 +1,447 @@
import { describe, it, expect } from 'vitest';
import {
IsString, IsInt, Min, MinLength, Matches, IsIn, ValidateNested, JsonType,
JsonPolymorphic, JsonSerialize, JsonSerializer, JsonMappingError,
toInstance, toInstanceArray, toPlain, toJson, fromJson, fromJsonArray, fromRequest, validate,
} from './index.js';
/**
* Each block here pins down a defect that the engine used to have. The comment above the
* block describes the old, wrong behaviour.
*/
describe('regressions', () => {
describe('inheritance', () => {
// Was: a subclass re-decorating an inherited property registered its constraints on its
// own prototype, and the engine read only the nearest set — so every rule the base class
// declared was silently dropped.
it('merges validation constraints across the prototype chain', async () => {
class Base {
@MinLength(5)
name: string;
}
class Sub extends Base {
@IsString()
declare name: string;
}
const s = new Sub();
s.name = 'ab'; // satisfies Sub's @IsString, violates Base's @MinLength(5)
const errors = await validate(s);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints).toHaveProperty('minLength');
});
it('enforces base constraints that the subclass never restates', async () => {
abstract class Media {
@IsString()
title: string;
}
class Book extends Media {
@IsString()
author: string;
}
const b = new Book();
b.title = 42 as any;
b.author = 'Fitzgerald';
const errors = await validate(b);
expect(errors.map(e => e.property)).toContain('title');
});
it('does not report an identical inherited rule twice', async () => {
class Base {
@IsString()
type: string;
}
class Sub extends Base {
@IsString()
declare type: string;
}
const s = new Sub();
s.type = 1 as any;
const errors = await validate(s);
expect(errors).toHaveLength(1);
expect(Object.keys(errors[0]!.constraints)).toEqual(['isString']);
});
});
describe('cycles', () => {
// Was: serialize() recursed forever on a cycle, exhausting an 8 GB heap and killing the
// process. A clear error beats an OOM.
it('reports a circular reference instead of exhausting the heap', async () => {
class Node {
@IsString()
name: string;
next?: any;
}
const a = new Node();
a.name = 'a';
a.next = a;
await expect(toPlain(a)).rejects.toThrow(JsonMappingError);
await expect(toPlain(a)).rejects.toThrow(/Circular reference/);
});
it('still serializes a diamond, where one object is referenced twice', async () => {
class Leaf {
@IsString()
id: string;
}
class Holder {
left: Leaf;
right: Leaf;
}
const shared = new Leaf();
shared.id = 'shared';
const h = new Holder();
h.left = shared;
h.right = shared;
const plain = await toPlain(h);
expect(plain).toEqual({ left: { id: 'shared' }, right: { id: 'shared' } });
});
it('terminates when validating a cyclic @ValidateNested graph', async () => {
class Person {
@IsString()
name: string;
@ValidateNested()
friend?: Person;
}
const a = new Person();
a.name = 'a';
const b = new Person();
b.name = 'b';
a.friend = b;
b.friend = a;
await expect(validate(a)).resolves.toEqual([]);
});
});
describe('messages', () => {
// Was: the "each element in ..." prefix was glued onto every message, including ones the
// caller wrote, producing "each element in tags must all be strings".
it('reports a caller-supplied message verbatim under each:true', async () => {
class T {
@IsString({ each: true, message: 'tags must all be strings' })
tags: any[];
}
const t = new T();
t.tags = [1];
const errors = await validate(t);
expect(errors[0]!.constraints['isString']).toBe('tags must all be strings');
});
it('still prefixes the library default message under each:true', async () => {
class T {
@IsString({ each: true })
tags: any[];
}
const t = new T();
t.tags = [1];
const errors = await validate(t);
expect(errors[0]!.constraints['isString']).toContain('each element in');
});
// Was: two constraints sharing a name overwrote each other in the error record, so only
// the last failure was ever reported.
it('keeps every failure when two rules share a name', async () => {
class T {
@Min(10)
@Min(5)
n: number;
}
const t = new T();
t.n = 1;
const errors = await validate(t);
const messages = Object.values(errors[0]!.constraints);
expect(messages).toHaveLength(2);
expect(messages).toEqual(expect.arrayContaining([
'n must be at least 5',
'n must be at least 10',
]));
});
});
describe('@Matches', () => {
// Was: a /g regex kept its lastIndex between calls, so validating the same value twice
// gave different answers — the second call spuriously failed.
it('is stateless when the pattern carries a g flag', async () => {
class T {
@Matches(/^[a-z]+$/g)
v: string;
}
const t = new T();
t.v = 'abc';
expect(await validate(t)).toHaveLength(0);
expect(await validate(t)).toHaveLength(0);
expect(await validate(t)).toHaveLength(0);
});
it('is stateless when the pattern carries a y flag', async () => {
class T {
@Matches(/^[a-z]+$/y)
v: string;
}
const t = new T();
t.v = 'abc';
expect(await validate(t)).toHaveLength(0);
expect(await validate(t)).toHaveLength(0);
});
});
describe('@JsonPolymorphic', () => {
abstract class Animal {
@IsString()
type: string;
}
class Dog extends Animal {
@IsString()
breed: string;
}
// Was: when the discriminator matched no subtype, the single-object branch fell through
// without assigning anything, so the property came back `undefined` and the caller's data
// vanished without a word.
it('keeps the raw value when the discriminator matches nothing', async () => {
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
pet: Animal;
}
const h = await toInstance(Holder, { pet: { type: 'cat', sound: 'meow' } });
expect(h.pet).toBeDefined();
expect(h.pet).toEqual({ type: 'cat', sound: 'meow' });
});
it('can be told to reject an unknown discriminator instead', async () => {
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { onUnknown: 'error' })
pet: Animal;
}
await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(JsonMappingError);
await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(/Unknown discriminator/);
});
it('can fall back to a default subtype', async () => {
class Unknown extends Animal {
@IsString()
override type = 'unknown';
}
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { fallback: Unknown })
pet: Animal;
}
const h = await toInstance(Holder, { pet: { type: 'cat' } });
expect(h.pet).toBeInstanceOf(Unknown);
});
it('keeps unmatched entries inside an array', async () => {
class Holder {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
pets: Animal[];
}
const h = await toInstance(Holder, {
pets: [{ type: 'dog', breed: 'Lab' }, { type: 'cat', sound: 'meow' }],
});
expect(h.pets[0]).toBeInstanceOf(Dog);
expect(h.pets[1]).toEqual({ type: 'cat', sound: 'meow' });
});
});
describe('prototype handling', () => {
// Was: serialize() read `obj.constructor.prototype`, which throws for an object created
// with a null prototype because it has no `constructor`.
it('serializes a null-prototype object', async () => {
const o = Object.create(null);
o.a = 1;
o.b = { c: 2 };
await expect(toPlain(o)).resolves.toEqual({ a: 1, b: { c: 2 } });
});
// Was: `__proto__` arriving in a JSON body was copied straight onto the instance, which
// swaps the instance's prototype and detaches it from its own class.
it('drops __proto__ from untrusted input', async () => {
class Dto {
@IsString()
name: string;
}
const malicious = JSON.parse('{"name":"x","__proto__":{"polluted":"yes"}}');
const dto = await toInstance(Dto, malicious);
expect(dto).toBeInstanceOf(Dto);
expect(Object.getPrototypeOf(dto)).toBe(Dto.prototype);
expect(({} as any).polluted).toBeUndefined();
});
it('drops constructor and prototype keys from untrusted input', async () => {
class Dto {
@IsString()
name: string;
}
const dto = await toInstance(Dto, JSON.parse('{"name":"x","constructor":1,"prototype":2}'));
expect(dto.constructor).toBe(Dto);
expect((dto as any).prototype).toBeUndefined();
});
});
describe('custom serializers', () => {
// Was: a @JsonSerialize serializer was invoked even when the property was null or
// undefined, so any serializer that touched the value crashed on an unset optional field.
it('is skipped for an unset optional property', async () => {
class IsoDate implements JsonSerializer<Date, string> {
serialize(value: Date): string {
return value.toISOString();
}
}
class T {
@JsonSerialize(IsoDate)
when?: Date | undefined;
@IsString()
other: string;
}
const t = new T();
t.other = 'x';
t.when = undefined;
await expect(toPlain(t)).resolves.toEqual({ when: undefined, other: 'x' });
});
it('still runs for a property that has a value', async () => {
class IsoDate implements JsonSerializer<Date, string> {
serialize(value: Date): string {
return value.toISOString().slice(0, 10);
}
}
class T {
@JsonSerialize(IsoDate)
when: Date;
}
const t = new T();
t.when = new Date('1925-04-10T00:00:00Z');
await expect(toJson(t)).resolves.toBe('{"when":"1925-04-10"}');
});
});
describe('array entry points', () => {
class Item {
@IsString()
name: string;
}
// Was: `toInstance`/`fromJson` accepted arrays at runtime but typed the result as `T`,
// so consumers had to cast to reach the elements.
it('toInstanceArray returns a correctly typed array', async () => {
const items = await toInstanceArray(Item, [{ name: 'a' }, { name: 'b' }]);
expect(items).toHaveLength(2);
expect(items[0]).toBeInstanceOf(Item);
expect(items[0]!.name).toBe('a');
});
it('fromJsonArray parses and validates a JSON array', async () => {
const items = await fromJsonArray(Item, '[{"name":"a"}]');
expect(items[0]!.name).toBe('a');
});
it('toInstanceArray rejects a non-array payload', async () => {
await expect(toInstanceArray(Item, {} as any)).rejects.toThrow(JsonMappingError);
});
it('fromJson still accepts an array for backwards compatibility', async () => {
const items = (await fromJson(Item, '[{"name":"a"}]')) as unknown as Item[];
expect(Array.isArray(items)).toBe(true);
});
});
describe('fromRequest', () => {
it('reports a non-JSON body as a mapping error', async () => {
class Dto {
@IsString()
name: string;
}
const request = new Request('https://example.com', { method: 'POST', body: 'not json' });
await expect(fromRequest(Dto, request)).rejects.toThrow(JsonMappingError);
await expect(
fromRequest(Dto, new Request('https://example.com', { method: 'POST', body: '' }))
).rejects.toThrow(/not valid JSON/);
});
});
describe('@ValidateNested', () => {
it('accepts the documented { each: true } option', async () => {
class Item {
@IsInt()
@Min(1)
qty: number;
}
class Order {
@ValidateNested({ each: true })
@JsonType(() => Item)
items: Item[];
}
const bad = new Item();
bad.qty = -5;
const o = new Order();
o.items = [bad];
const errors = await validate(o);
expect(errors).toHaveLength(1);
expect(errors[0]!.children?.[0]?.children?.[0]?.property).toBe('qty');
});
it('{ each: true } asserts the value really is an array', async () => {
class Item {
@IsInt()
qty: number;
}
class Order {
@ValidateNested({ each: true })
items: Item[];
}
const o = new Order();
o.items = 'nope' as any;
const errors = await validate(o);
expect(errors[0]!.constraints).toHaveProperty('nestedEach');
});
});
describe('@IsIn with each:true', () => {
it('rejects a non-array value rather than passing it through', async () => {
class T {
@IsIn(['a', 'b'], { each: true })
tags: any;
}
const t = new T();
t.tags = 'not-allowed';
expect(await validate(t)).toHaveLength(1);
});
});
});
+400
View File
@@ -0,0 +1,400 @@
import { describe, it, expect } from 'vitest';
import {
IsString, IsInt, Min, MinLength, IsIn, ValidateNested, JsonType, JsonProperty,
JsonSerialize, JsonDeserialize, JsonSerializer, JsonDeserializer,
JsonIgnore, JsonWriteOnly, Validate, JsonMappingError, JsonValidationError, REDACTED,
validate, validateSync, validateOrReject, validateOrRejectSync,
toPlain, toPlainSync, toJson, toJsonSync,
toInstance, toInstanceSync, toInstanceArray, toInstanceArraySync,
fromJsonSync, fromJsonArraySync,
flattenErrors,
} from './index.js';
class Upper implements JsonSerializer<string, string> {
serialize(value: string): string { return value.toUpperCase(); }
}
class Lower implements JsonDeserializer<string, string> {
deserialize(value: string): string { return value.toLowerCase(); }
}
class User {
@JsonProperty('display_name')
@IsString()
@MinLength(2)
displayName: string;
@IsInt()
@Min(0)
age: number;
}
describe('synchronous API', () => {
it('toInstanceSync / fromJsonSync map and validate without a Promise', () => {
const user = toInstanceSync(User, { display_name: 'Ada', age: 36 });
expect(user).toBeInstanceOf(User);
expect(user.displayName).toBe('Ada');
const parsed = fromJsonSync(User, '{"display_name":"Ada","age":36}');
expect(parsed.displayName).toBe('Ada');
});
it('toPlainSync / toJsonSync round-trip', () => {
const user = new User();
user.displayName = 'Ada';
user.age = 36;
expect(toPlainSync(user)).toEqual({ display_name: 'Ada', age: 36 });
expect(toJsonSync(user)).toBe('{"display_name":"Ada","age":36}');
});
it('validateSync returns the same errors as validate', async () => {
const user = new User();
user.displayName = 'A';
user.age = -1;
const sync = validateSync(user);
const async = await validate(user);
expect(flattenErrors(sync)).toEqual(flattenErrors(async));
expect(Object.keys(flattenErrors(sync))).toEqual(['displayName', 'age']);
});
it('throws JsonValidationError on invalid input, like the async form', () => {
expect(() => toInstanceSync(User, { display_name: 'A', age: 5 })).toThrow(JsonValidationError);
expect(() => validateOrRejectSync(Object.assign(new User(), { displayName: 'A', age: 1 })))
.toThrow(JsonValidationError);
});
it('honours options', () => {
const lenient = toInstanceSync(User, { display_name: 'A', age: -1 }, { validate: false });
expect(lenient.displayName).toBe('A');
expect(() => toInstanceSync(User, { display_name: 'Ada', age: 1, stray: 1 }, { unknownKeys: 'error' }))
.toThrow(/Unknown property "stray"/);
});
it('array entry points work synchronously', () => {
class Item {
@IsString()
name: string;
}
expect(toInstanceArraySync(Item, [{ name: 'a' }])[0]!.name).toBe('a');
expect(fromJsonArraySync(Item, '[{"name":"b"}]')[0]!.name).toBe('b');
expect(() => toInstanceArraySync(Item, {} as any)).toThrow(JsonMappingError);
});
it('runs synchronous custom serializers and deserializers', () => {
class Doc {
@JsonSerialize(Upper)
@JsonDeserialize(Lower)
code: string;
}
const doc = toInstanceSync(Doc, { code: 'ABC' }, { validate: false });
expect(doc.code).toBe('abc');
expect(toPlainSync(doc)).toEqual({ code: 'ABC' });
});
it('handles nesting, cycles and depth the same way', () => {
class Child { @IsString() name: string; }
class Parent {
@ValidateNested()
@JsonType(() => Child)
child: Child;
}
const parent = toInstanceSync(Parent, { child: { name: 'x' } });
expect(parent.child).toBeInstanceOf(Child);
const cyclic: any = new Parent();
cyclic.child = cyclic;
expect(() => toPlainSync(cyclic, { validate: false })).toThrow(/Circular reference/);
});
});
describe('synchronous API refuses asynchronous hooks', () => {
class SlowSerializer implements JsonSerializer<string, string> {
async serialize(value: string): Promise<string> { return value.toUpperCase(); }
}
class SlowDeserializer implements JsonDeserializer<string, string> {
async deserialize(value: string): Promise<string> { return value.toLowerCase(); }
}
it('reports a clear error for an async serializer and names the async alternative', () => {
class Doc {
@JsonSerialize(SlowSerializer)
code: string;
}
const doc = new Doc();
doc.code = 'abc';
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
expect(() => toPlainSync(doc, { validate: false })).toThrow(/toPlainSync\(\) requires every/);
expect(() => toPlainSync(doc, { validate: false })).toThrow(/Use toPlain\(\) instead/);
});
it('reports a clear error for an async deserializer', () => {
class Doc {
@JsonDeserialize(SlowDeserializer)
code: string;
}
expect(() => toInstanceSync(Doc, { code: 'ABC' }, { validate: false }))
.toThrow(/toInstanceSync\(\) requires every/);
});
it('reports a clear error for an async validator', () => {
class Doc {
@Validate(async (v: any) => v === 'ok')
code: string;
}
const doc = new Doc();
doc.code = 'ok';
expect(() => validateSync(doc)).toThrow(/validateSync\(\) requires every/);
});
it('does not leave an unhandled rejection behind when it refuses', async () => {
class Exploding implements JsonSerializer<string, string> {
serialize(): Promise<string> { return Promise.reject(new Error('boom')); }
}
class Doc {
@JsonSerialize(Exploding)
code: string;
}
const doc = new Doc();
doc.code = 'x';
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
await new Promise(resolve => setTimeout(resolve, 20));
} finally {
process.off('unhandledRejection', onUnhandled);
}
expect(unhandled).toEqual([]);
});
});
describe('the async API still supports asynchronous hooks', () => {
it('awaits an async serializer', async () => {
class Slow implements JsonSerializer<string, string> {
async serialize(value: string): Promise<string> {
await new Promise(resolve => setTimeout(resolve, 1));
return value.toUpperCase();
}
}
class Doc {
@JsonSerialize(Slow)
code: string;
@IsString()
other: string;
}
const doc = new Doc();
doc.code = 'abc';
doc.other = 'kept';
await expect(toPlain(doc)).resolves.toEqual({ code: 'ABC', other: 'kept' });
await expect(toJson(doc)).resolves.toBe('{"code":"ABC","other":"kept"}');
});
it('awaits an async deserializer, including inside a nested type', async () => {
class Slow implements JsonDeserializer<string, Date> {
async deserialize(value: string): Promise<Date> {
await new Promise(resolve => setTimeout(resolve, 1));
return new Date(value);
}
}
class Child {
@JsonDeserialize(Slow)
at: Date;
}
class Parent {
@JsonType(() => Child)
child: Child;
}
const parent = await toInstance(Parent, { child: { at: '2026-01-01T00:00:00Z' } }, { validate: false });
expect(parent.child.at).toBeInstanceOf(Date);
expect(parent.child.at.getUTCFullYear()).toBe(2026);
});
it('awaits an async deserializer inside an array', async () => {
class Slow implements JsonDeserializer<string, string> {
async deserialize(value: string): Promise<string> { return value.toUpperCase(); }
}
class Row {
@JsonDeserialize(Slow)
code: string;
}
const rows = await toInstanceArray(Row, [{ code: 'a' }, { code: 'b' }], { validate: false });
expect(rows.map(r => r.code)).toEqual(['A', 'B']);
});
it('awaits an async validator and reports its failure', async () => {
class Doc {
@Validate(async (v: any) => {
await new Promise(resolve => setTimeout(resolve, 1));
return v === 'ok';
}, { message: 'must be ok' })
code: string;
}
const doc = new Doc();
doc.code = 'ok';
await expect(validate(doc)).resolves.toEqual([]);
doc.code = 'wrong';
const errors = await validate(doc);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints['custom']).toBe('must be ok');
});
it('awaits an async validator under each: true and keeps the index', async () => {
class Doc {
@Validate(async (v: any) => v === 'ok', { each: true })
codes: string[];
}
const doc = new Doc();
doc.codes = ['ok', 'ok'];
await expect(validate(doc)).resolves.toEqual([]);
doc.codes = ['ok', 'ok', 'bad'];
const errors = await validate(doc);
expect(errors).toHaveLength(1);
expect(errors[0]!.constraints['custom']).toContain('failed at index 2');
});
it('mixes sync and async validators on one object without losing either failure', async () => {
class Doc {
@IsString()
name: any;
@Validate(async (v: any) => v > 0, { message: 'must be positive' })
amount: number;
}
const doc = new Doc();
doc.name = 123;
doc.amount = -5;
const flat = flattenErrors(await validate(doc));
expect(flat['name']).toEqual(['name must be a string']);
expect(flat['amount']).toEqual(['must be positive']);
});
it('prunes provisional entries for async validators that pass', async () => {
class Doc {
@Validate(async () => true)
a: string;
@Validate(async () => true)
b: string;
}
await expect(validate(new Doc())).resolves.toEqual([]);
});
it('validateOrReject still rejects on an async failure', async () => {
class Doc {
@Validate(async () => false)
code: string;
}
await expect(validateOrReject(new Doc())).rejects.toThrow(JsonValidationError);
});
});
describe('write-only redaction in validation errors', () => {
it('redacts a @JsonWriteOnly value but keeps the failure message', async () => {
class Credentials {
@IsString()
email: string;
@JsonWriteOnly()
@IsString()
@MinLength(12)
password: string;
}
const creds = new Credentials();
creds.email = 'ada@example.com';
creds.password = 'hunter2';
const errors = await validate(creds);
const failure = errors.find(e => e.property === 'password')!;
expect(failure.value).toBe(REDACTED);
expect(failure.constraints['minLength']).toContain('12');
expect(JSON.stringify(errors)).not.toContain('hunter2');
});
it('redacts @JsonIgnore values too', async () => {
class Record {
@JsonIgnore()
@IsString()
internalSecret: any;
}
const record = new Record();
record.internalSecret = 999;
const errors = await validate(record);
expect(errors[0]!.value).toBe(REDACTED);
expect(JSON.stringify(errors)).not.toContain('999');
});
it('leaves ordinary property values in place', async () => {
class Doc {
@IsString()
name: any;
}
const doc = new Doc();
doc.name = 42;
const errors = await validate(doc);
expect(errors[0]!.value).toBe(42);
});
it('keeps the secret out of a thrown JsonValidationError', async () => {
class SignUp {
@JsonWriteOnly()
@IsString()
@MinLength(12)
password: string;
}
await expect(toInstance(SignUp, { password: 'short' })).rejects.toThrow(JsonValidationError);
try {
await toInstance(SignUp, { password: 'short' });
} catch (error) {
expect(String((error as JsonValidationError).toString())).not.toContain('short');
}
});
it('redacts in the synchronous path as well', () => {
class Credentials {
@JsonWriteOnly()
@IsString()
@MinLength(12)
password: string;
}
const creds = new Credentials();
creds.password = 'hunter2';
expect(validateSync(creds)[0]!.value).toBe(REDACTED);
});
it('does not redact a value that merely sits next to a secret', async () => {
class Form {
@IsIn(['a', 'b'])
choice: string;
@JsonWriteOnly()
@IsString()
token: string;
}
const form = new Form();
form.choice = 'zzz';
form.token = 'secret-token';
const errors = await validate(form);
expect(errors.find(e => e.property === 'choice')!.value).toBe('zzz');
expect(JSON.stringify(errors)).not.toContain('secret-token');
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ describe('Standalone Utility Functions', () => {
user.age = '30' as any; user.age = '30' as any;
const errors2 = await validate(user); const errors2 = await validate(user);
expect(errors2).toHaveLength(1); expect(errors2).toHaveLength(1);
expect(errors2[0].property).toBe('age'); expect(errors2[0]!.property).toBe('age');
}); });
it('should transform to plain object directly', async () => { it('should transform to plain object directly', async () => {
+862 -108
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
import { describe, it, expect } from 'vitest';
import {
Equals, NotEquals, IsEmpty, IsEnum, IsInstance,
Length, IsAlpha, IsAlphanumeric, IsNumberString, IsLowercase, IsUppercase,
Contains, NotContains, StartsWith, EndsWith,
IsUUID, IsJSON, IsDateString, IsSemVer, IsHexColor, IsIP,
IsDivisibleBy, IsPort, IsLatitude, IsLongitude, IsBigInt,
MinDate, MaxDate,
ArrayUnique, ArrayContains, ArrayNotContains,
ValidateIf, Allow, IsString, IsIn, IsOptional,
validate, toInstance,
} from './index.js';
/** Builds a one-property class, assigns `value`, and returns the constraint keys that failed. */
async function check(decorate: (target: any, key: string) => void, value: any): Promise<string[]> {
class Subject {
val: any;
}
decorate(Subject.prototype, 'val');
const subject = new Subject();
subject.val = value;
const errors = await validate(subject);
return errors.length ? Object.keys(errors[0]!.constraints) : [];
}
const passes = async (decorator: any, value: any) => expect(await check(decorator, value)).toEqual([]);
const fails = async (decorator: any, value: any) => expect((await check(decorator, value)).length).toBeGreaterThan(0);
describe('equality and presence', () => {
it('@Equals / @NotEquals', async () => {
await passes(Equals('x'), 'x');
await fails(Equals('x'), 'y');
await passes(NotEquals('x'), 'y');
await fails(NotEquals('x'), 'x');
});
it('@IsEmpty', async () => {
for (const empty of [null, undefined, '', [], {}]) await passes(IsEmpty(), empty);
for (const filled of ['a', [1], { a: 1 }, 0]) await fails(IsEmpty(), filled);
});
it('@IsInstance', async () => {
class Thing {}
await passes(IsInstance(Thing), new Thing());
await fails(IsInstance(Thing), {});
});
});
describe('@IsEnum', () => {
enum StringRole { Admin = 'admin', User = 'user' }
enum NumericLevel { Low, High }
it('accepts members of a string enum', async () => {
await passes(IsEnum(StringRole), 'admin');
await fails(IsEnum(StringRole), 'root');
});
it('accepts members of a numeric enum without accepting its reverse-mapped names', async () => {
await passes(IsEnum(NumericLevel), 0);
await passes(IsEnum(NumericLevel), 1);
await fails(IsEnum(NumericLevel), 2);
// 'Low' is the reverse mapping, not a legal value
await fails(IsEnum(NumericLevel), 'Low');
});
});
describe('strings', () => {
it('@Length with and without a maximum', async () => {
await passes(Length(2), 'ab');
await fails(Length(3), 'ab');
await passes(Length(2, 4), 'abc');
await fails(Length(2, 4), 'abcde');
});
it('@IsAlpha / @IsAlphanumeric', async () => {
await passes(IsAlpha(), 'abcDEF');
await fails(IsAlpha(), 'abc1');
await passes(IsAlphanumeric(), 'abc123');
await fails(IsAlphanumeric(), 'abc-123');
});
it('@IsNumberString', async () => {
await passes(IsNumberString(), '42');
await passes(IsNumberString(), '-1.5');
await fails(IsNumberString(), 'abc');
await fails(IsNumberString(), '');
await fails(IsNumberString(), 42);
});
it('@IsLowercase / @IsUppercase', async () => {
await passes(IsLowercase(), 'abc');
await fails(IsLowercase(), 'Abc');
await passes(IsUppercase(), 'ABC');
await fails(IsUppercase(), 'Abc');
});
it('@Contains / @NotContains / @StartsWith / @EndsWith', async () => {
await passes(Contains('ell'), 'hello');
await fails(Contains('xyz'), 'hello');
await passes(NotContains('xyz'), 'hello');
await fails(NotContains('ell'), 'hello');
await passes(StartsWith('he'), 'hello');
await fails(StartsWith('lo'), 'hello');
await passes(EndsWith('lo'), 'hello');
await fails(EndsWith('he'), 'hello');
});
});
describe('@IsUUID', () => {
const v4 = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
it('accepts any version when unversioned', async () => {
await passes(IsUUID(), v4);
await passes(IsUUID(), '00000000-0000-0000-0000-000000000000'); // nil
await fails(IsUUID(), 'not-a-uuid');
await fails(IsUUID(), 42);
});
it('enforces a requested version', async () => {
await passes(IsUUID(4), v4);
await fails(IsUUID(1), v4);
});
});
describe('formats', () => {
it('@IsJSON', async () => {
await passes(IsJSON(), '{"a":1}');
await passes(IsJSON(), '[1,2]');
await fails(IsJSON(), '{a:1}');
await fails(IsJSON(), { a: 1 });
});
it('@IsDateString', async () => {
await passes(IsDateString(), '2026-08-03T00:00:00Z');
await fails(IsDateString(), 'not a date');
});
it('@IsSemVer', async () => {
await passes(IsSemVer(), '1.2.3');
await passes(IsSemVer(), '1.0.0-alpha.1+build.5');
await fails(IsSemVer(), '1.2');
await fails(IsSemVer(), 'v1.2.3');
});
it('@IsHexColor', async () => {
await passes(IsHexColor(), '#fff');
await passes(IsHexColor(), '#A1B2C3');
await passes(IsHexColor(), '#A1B2C3FF');
await fails(IsHexColor(), 'fff');
await fails(IsHexColor(), '#ggg');
});
it('@IsIP', async () => {
await passes(IsIP(4), '192.168.0.1');
await fails(IsIP(4), '256.0.0.1');
await fails(IsIP(4), '::1');
await passes(IsIP(6), '::1');
await passes(IsIP(), '10.0.0.1');
await fails(IsIP(), 'nope');
});
});
describe('numbers', () => {
it('@IsDivisibleBy', async () => {
await passes(IsDivisibleBy(5), 10);
await fails(IsDivisibleBy(5), 11);
await fails(IsDivisibleBy(5), '10');
});
it('@IsPort', async () => {
await passes(IsPort(), 8080);
await passes(IsPort(), '443');
await fails(IsPort(), 70000);
await fails(IsPort(), -1);
await fails(IsPort(), 1.5);
});
it('@IsLatitude / @IsLongitude', async () => {
await passes(IsLatitude(), 48.85);
await fails(IsLatitude(), 91);
await passes(IsLongitude(), 2.35);
await fails(IsLongitude(), 181);
});
it('@IsBigInt', async () => {
await passes(IsBigInt(), 10n);
await fails(IsBigInt(), 10);
});
});
describe('dates', () => {
it('@MinDate / @MaxDate with a fixed bound', async () => {
const bound = new Date('2026-01-01T00:00:00Z');
await passes(MinDate(bound), new Date('2026-06-01T00:00:00Z'));
await fails(MinDate(bound), new Date('2025-06-01T00:00:00Z'));
await passes(MaxDate(bound), new Date('2025-06-01T00:00:00Z'));
await fails(MaxDate(bound), new Date('2026-06-01T00:00:00Z'));
});
it('@MinDate accepts a thunk so the bound moves', async () => {
await passes(MinDate(() => new Date(Date.now() - 1000)), new Date());
await fails(MinDate(() => new Date(Date.now() + 60_000)), new Date());
});
it('rejects a non-date', async () => {
await fails(MinDate(new Date(0)), '2026-01-01');
});
});
describe('arrays', () => {
it('@ArrayUnique by value', async () => {
await passes(ArrayUnique(), [1, 2, 3]);
await fails(ArrayUnique(), [1, 2, 2]);
});
it('@ArrayUnique by extracted key', async () => {
const byId = (item: any) => item.id;
await passes(ArrayUnique(byId), [{ id: 1 }, { id: 2 }]);
await fails(ArrayUnique(byId), [{ id: 1 }, { id: 1 }]);
});
it('@ArrayContains / @ArrayNotContains', async () => {
await passes(ArrayContains(['a']), ['a', 'b']);
await fails(ArrayContains(['c']), ['a', 'b']);
await passes(ArrayNotContains(['c']), ['a', 'b']);
await fails(ArrayNotContains(['a']), ['a', 'b']);
});
});
describe('@ValidateIf', () => {
class Payment {
@IsIn(['card', 'invoice'])
method: string;
@ValidateIf(o => o.method === 'card')
@IsString()
cardNumber?: string;
}
it('skips the constraint when the condition is false', async () => {
const p = new Payment();
p.method = 'invoice';
expect(await validate(p)).toEqual([]);
});
it('applies the constraint when the condition is true', async () => {
const p = new Payment();
p.method = 'card';
const errors = await validate(p);
expect(errors).toHaveLength(1);
expect(errors[0]!.property).toBe('cardNumber');
});
it('passes when the condition is true and the value is valid', async () => {
const p = new Payment();
p.method = 'card';
p.cardNumber = '4111111111111111';
expect(await validate(p)).toEqual([]);
});
});
describe('@Allow', () => {
it('declares a property so strict unknown-key policies keep it', async () => {
class Dto {
@IsString()
name: string;
@Allow()
metadata: unknown;
}
const d = await toInstance(
Dto,
{ name: 'x', metadata: { anything: true }, stray: 1 },
{ unknownKeys: 'strip' }
);
expect(d.metadata).toEqual({ anything: true });
expect((d as any).stray).toBeUndefined();
});
});
describe('new validators cooperate with existing options', () => {
it('honours each: true', async () => {
class T {
@IsUUID(4, { each: true })
ids: string[];
}
const t = new T();
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21'];
expect(await validate(t)).toEqual([]);
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21', 'nope'];
expect(await validate(t)).toHaveLength(1);
});
it('honours @IsOptional', async () => {
class T {
@IsOptional()
@IsSemVer()
version?: string;
}
const t = new T();
expect(await validate(t)).toEqual([]);
t.version = 'bad';
expect(await validate(t)).toHaveLength(1);
});
it('honours a custom message', async () => {
class T {
@IsPort({ message: 'give me a real port' })
port: number;
}
const t = new T();
t.port = -1;
const errors = await validate(t);
expect(errors[0]!.constraints['isPort']).toBe('give me a real port');
});
});
+2 -1
View File
@@ -5,5 +5,6 @@
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"outDir": "dist/cjs", "outDir": "dist/cjs",
"declaration": true "declaration": true
} },
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
} }
+2 -1
View File
@@ -4,5 +4,6 @@
"module": "NodeNext", "module": "NodeNext",
"outDir": "dist/esm", "outDir": "dist/esm",
"declaration": true "declaration": true
} },
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
} }
+3 -1
View File
@@ -35,6 +35,8 @@
"experimentalDecorators": true "experimentalDecorators": true
}, },
// NOTE: test files are deliberately included here so that `npm run type-check`
// covers them. The two build configs exclude them (and the demo) from `dist`.
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"] "exclude": ["node_modules", "dist"]
} }
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
// Vitest 4 transpiles with oxc, which does not read `experimentalDecorators`
// out of tsconfig.json for files the tsconfig does not `include`. Without this
// the decorator syntax in the test files fails to parse and every suite is
// silently reported as "0 test".
oxc: {
decorator: { legacy: true },
},
test: {
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/example.ts', 'src/index.ts'],
},
},
});