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
86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
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,
|
|
};
|
|
}
|