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
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_nametofirstNameper 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 isnull/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.optionsaccepts{ onUnknown: 'keep' | 'error' }(defaultkeep, 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 FetchRequest(Promise<T>).validate(obj, options?): Full validation, returningPromise<ValidationError[]>.validateOrReject(obj): As above, but throwsJsonValidationError.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 fromtoInstance, 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@JsonAliasto 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.