# 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 ```bash npm install cereale ``` Enable `experimentalDecorators` in your `tsconfig.json`: ```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 ```typescript import { IsString, IsDate, ValidateNested, JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonSerializer, JsonDeserializer } from 'cereale'; // Custom Date Serializer class DateSerializer implements JsonSerializer { serialize(value: Date): string { return value.toISOString().split('T')[0]!; } } class DateDeserializer implements JsonDeserializer { 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 ```typescript 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. ```typescript 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. ```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' }); ``` ## 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)`: Custom serializer for a property. Skipped when the value is `null`/`undefined`. - `@JsonDeserialize(deserializer: ClassConstructor)`: Custom deserializer for a property. - `@JsonType(typeFunction: () => ClassConstructor)`: 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`). - `toPlain(obj, options?)`: Validates and transforms an instance to a plain object (`Promise`). - `fromJson(clazz, json, options?)`: Parses JSON to a validated class instance (`Promise`). - `fromJsonArray(clazz, json, options?)`: Same, for a JSON array (`Promise`). - `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise`). - `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise`). - `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise`). - `validate(obj, options?)`: Full validation, returning `Promise`. - `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: ```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"] ``` `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`. ```typescript import { toInstance } from 'cereale'; @Post() async create(@Body() body: any) { const user = await toInstance(User, body); return this.userService.create(user); } ``` ### Express / Fastify ```typescript 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](CONTRIBUTING.md) for details on how to contribute to this project. ## License Cereale is licensed under the [MIT License](LICENSE).