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
2026-04-11 16:20:19 +02:00

Cereale

Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support.

Features

  • 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.
  • Polymorphism Support: Native handling of polymorphic types via discriminators.
  • Integrated Validation: 50+ validation decorators, applied during mapping or on demand.
  • Type Safety: Fully written in TypeScript for excellent developer experience.
  • Zero Dependencies: Extremely lightweight and fast.

Installation

npm install cereale

Enable experimentalDecorators in your tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "target": "ES2022"
  }
}

Cereale stores its own metadata, so reflect-metadata is not required and emitDecoratorMetadata is not read. Requires Node 20 or later.

Quick Start

1. Define your Models

import {
  IsString,
  IsDate,
  ValidateNested,
  JsonSerialize,
  JsonDeserialize,
  JsonPolymorphic,
  JsonSerializer,
  JsonDeserializer
} from 'cereale';

// Custom Date Serializer
class DateSerializer implements JsonSerializer<Date, string> {
  serialize(value: Date): string {
    return value.toISOString().split('T')[0]!;
  }
}

class DateDeserializer implements JsonDeserializer<string, Date> {
  deserialize(value: string): Date {
    return new Date(value);
  }
}

abstract class Media {
  @IsString()
  abstract type: string;

  @IsString()
  title: string;
}

class Book extends Media {
  type = 'book';

  @IsString()
  author: string;

  @JsonSerialize(DateSerializer)
  @JsonDeserialize(DateDeserializer)
  @IsDate()
  publishedAt: Date;
}

class Library {
  @IsString()
  name: string;

  @ValidateNested({ each: true })
  @JsonPolymorphic('type', [
    { value: Book, name: 'book' }
  ])
  items: Media[];
}

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

import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale';

async function main() {
  const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';

  try {
    // Deserialize JSON to Class Instance
    const library = await fromJson(Library, json);
    console.log(library.name);                     // "Central Library"
    console.log(library.items[0] instanceof Book); // true

    // Serialize Class Instance back to JSON
    console.log(await toJson(library));
  } catch (error) {
    if (error instanceof JsonValidationError) {
      console.error(flattenErrors(error.errors));
      // { "items[0].title": ["title must be a string"] }
    }
  }
}

3. Modern Web Frameworks (Request Integration)

Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the fromRequest async helper.

import { fromRequest, toPlain } from 'cereale';

// Hono Example
app.post('/books', async (c) => {
  const book = await fromRequest(Book, c.req.raw);
  return c.json(await toPlain(book));
});

Field-name Mapping

JSON rarely uses the same names as your classes.

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:

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

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.
// 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' });

API Reference

Mapping Decorators

  • @JsonProperty(name: string): Renames the property in JSON, both directions.
  • @JsonAlias(...names: string[]): Extra names accepted on input only.
  • @JsonIgnore(): Excludes the property from mapping entirely.
  • @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

Most validation decorators accept an optional ValidationOptions object:

  • each: boolean: Apply validation to each element of an array.
  • message: string | ((args: ValidationArguments) => string): Custom error message, reported verbatim.
Decorator Description
@IsString() Checks if value is a string.
@IsNumber() Checks if value is a number (and not NaN).
@IsInt() Checks if value is an integer.
@IsBoolean() Checks if value is a boolean.
@IsBigInt() Checks if value is a bigint.
@IsObject() Checks if value is an object (not null/array).
@IsDate() Checks if value is a valid Date object.
@IsDefined() Checks if value is not null or undefined.
@IsOptional() Skips other validations if value is null/undefined.
@IsNotEmpty() Checks if value is not null/undefined/empty string.
@IsEmpty() Checks if value is null/undefined/''/[]/{}.
@Equals(value) / @NotEquals(value) Strict equality against a fixed value.
@IsEnum(enumObject) Checks membership of a TypeScript enum.
@IsInstance(Class) Checks value instanceof Class.
@Min(value) / @Max(value) Numeric bounds.
@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.
@IsUrl() Checks if string is a valid URL.
@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.
@ArrayNotEmpty() Checks if array is not empty.
@ArrayMinSize(n) / @ArrayMaxSize(n) Array size bounds.
@ArrayUnique(keyFn?) Checks for duplicate elements.
@ArrayContains(vals) / @ArrayNotContains(vals) Membership checks.
@IsIn(values) / @IsNotIn(values) Allow/deny lists.
@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

  • toJson(obj, options?): Validates and serializes an instance to a JSON string (Promise<string>).
  • toPlain(obj, options?): Validates and transforms an instance to a plain object (Promise<any>).
  • fromJson(clazz, json, options?): Parses JSON to a validated class instance (Promise<T>).
  • fromJsonArray(clazz, json, options?): Same, for a JSON array (Promise<T[]>).
  • toInstance(clazz, plain, options?): Transforms a plain object to a validated class instance (Promise<T>).
  • 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): As above, but throws JsonValidationError.
  • 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:

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"]

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

Hono / Next.js / Cloudflare Workers

Use fromRequest for seamless integration with the Fetch Request API.

NestJS

Use Cereale inside your controllers for explicit mapping and validation without needing reflect-metadata.

import { toInstance } from 'cereale';

@Post()
async create(@Body() body: any) {
  const user = await toInstance(User, body);
  return this.userService.create(user);
}

Express / Fastify

import { toInstance, toPlain, JsonValidationError, flattenErrors } from 'cereale';

app.post('/user', async (req, res) => {
  try {
    const user = await toInstance(User, req.body);
    res.json(await toPlain(user));
  } catch (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.

Indicative throughput for a customer record with a nested address and 10 orders, measured against JSON.parse + JSON.stringify (5.9 us) on the same machine:

Operation Time
toInstance (deserialize + validate) ~19 us
toInstance with { validate: false } ~6 us
validate on an existing instance ~13 us
toPlain (validate + serialize) ~23 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

Please see CONTRIBUTING.md for details on how to contribute to this project.

License

Cereale is licensed under the MIT License.

S
Description
Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation.
https://avalon-vanguard.github.io/cereale/
Readme MIT
971 KiB
Languages
TypeScript 89.6%
JavaScript 10.4%