@amritk/generate-validators
Advanced tools
| import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern'; | ||
| import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension'; | ||
| import { refToName } from '@amritk/helpers/ref-to-name'; | ||
| import { safeAccessor } from '@amritk/helpers/safe-accessor'; | ||
| import { hasAdditionalProperties, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards'; | ||
@@ -408,3 +409,4 @@ import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check'; | ||
| return null; | ||
| const raw = `${objAcc}[${JSON.stringify(key)}]`; | ||
| // Dotted access (`obj.number`) for identifier keys, bracket access otherwise. | ||
| const raw = safeAccessor(objAcc, key); | ||
| // Anything the slow path enforces past a typeof is cheaper to leave to the | ||
@@ -451,3 +453,31 @@ // slow path than to mirror here, so bail and keep the guard sound. | ||
| }; | ||
| /** A property key an array carries with a non-`undefined` value: `length`, or a | ||
| * canonical array index. A required prop on one of these can't be used to rule | ||
| * out arrays (an array's `length` is a number, an index can be anything). */ | ||
| const ARRAY_INDEX_KEY = /^(0|[1-9]\d*)$/; | ||
| /** Schema types whose guard is a `typeof` check `typeof undefined` never passes. */ | ||
| const TYPEOF_CHECKABLE_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object']); | ||
| /** | ||
| * Whether some required, typeof-guarded property proves the value can't be an | ||
| * array — letting the object shape-check drop its `!Array.isArray(...)` term. An | ||
| * array indexed by a normal key yields `undefined` (or an inherited method), | ||
| * which no `typeof === 'string' | 'number' | 'boolean' | 'object'` accepts, so | ||
| * that field check already rejects arrays. Keys an array does carry a real value | ||
| * for (`length`, numeric indices) are excluded, since those could slip through. | ||
| */ | ||
| const arrayRejectedByRequiredProp = (keys, required, properties) => { | ||
| for (const key of keys) { | ||
| if (!required.has(key) || key === 'length' || ARRAY_INDEX_KEY.test(key)) | ||
| continue; | ||
| const propSchema = properties[key]; | ||
| if (propSchema !== undefined && | ||
| isSchemaObject(propSchema) && | ||
| hasType(propSchema) && | ||
| TYPEOF_CHECKABLE_TYPES.has(propSchema.type)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| }; | ||
| /** | ||
| * Builds the allocation-free boolean guard for an object schema as a list of | ||
@@ -484,3 +514,6 @@ * `&&` conditions, or `null` when the schema can't be proven valid by a cheap | ||
| const keys = Object.keys(properties); | ||
| const conditions = [`typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})`]; | ||
| // The object shape-check only needs `!Array.isArray` when no required field | ||
| // check would already reject an array (see `arrayRejectedByRequiredProp`). | ||
| const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? '' : ` && !Array.isArray(${raw})`; | ||
| const conditions = [`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`]; | ||
| for (const key of keys) { | ||
@@ -559,15 +592,14 @@ // An optional property would need an `=== undefined ||` branch and breaks | ||
| // well-typed (and, for strict objects, there are no extras) it returns true | ||
| // without allocating an `errors` array or walking the slow path. It returns | ||
| // without allocating an `errors` array or touching the slow path. It returns | ||
| // true only for provably valid input; anything it can't prove cheaply falls | ||
| // through to the error-collecting body below, which produces the same verdict | ||
| // and full JSON-Pointer errors. Schemas with constraints the guard can't | ||
| // express produce no guard at all (`null`), leaving behaviour unchanged. | ||
| // through to the error-collecting path, which produces the same verdict and | ||
| // full JSON-Pointer errors. Schemas with constraints the guard can't express | ||
| // produce no guard at all (`null`), leaving behaviour unchanged. | ||
| const guard = guardObjectConditions(schema, 'input', 'obj'); | ||
| const guardBlock = guard | ||
| ? [` if (`, guard.map((condition) => ` ${condition}`).join(' &&\n'), ` ) {`, ` return true`, ` }`, ``] | ||
| : []; | ||
| return [ | ||
| `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`, | ||
| // The cold, error-collecting body. When there's a guard this is a separate | ||
| // (unexported) function reached only on failure; the hot path never enters it | ||
| // unless input is actually invalid, so its size never costs the happy path. | ||
| const collectBody = (name, exported) => [ | ||
| `${exported ? 'export ' : ''}const ${name} = (input: unknown, _path = ''): ValidationResult => {`, | ||
| ` const obj = input as Record<string, unknown>`, | ||
| ...guardBlock, | ||
| ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`, | ||
@@ -582,2 +614,27 @@ ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`, | ||
| ].join('\n'); | ||
| // No guard: the exported validator is the error-collecting function itself. | ||
| if (!guard) { | ||
| return `${hoistedBlock}${collectBody(vName, true)}`; | ||
| } | ||
| // With a guard, keep the happy path inside the exported function — the guard | ||
| // is inlined as an early `return true`, so a valid input never pays an extra | ||
| // call — and move only the cold, error-collecting body into a separate | ||
| // (unexported) function. That keeps `validateX` itself tiny (guard + a single | ||
| // tail call) so V8 optimises it well, without the giant error body bloating | ||
| // the hot path. The exported `(input, _path?) => ValidationResult` contract | ||
| // is unchanged. | ||
| const collectName = `${vName}Errors`; | ||
| return [ | ||
| `${hoistedBlock}${collectBody(collectName, false)}`, | ||
| ``, | ||
| `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`, | ||
| ` const obj = input as Record<string, unknown>`, | ||
| ` if (`, | ||
| guard.map((condition) => ` ${condition}`).join(' &&\n'), | ||
| ` ) {`, | ||
| ` return true`, | ||
| ` }`, | ||
| ` return ${collectName}(input, _path)`, | ||
| `}`, | ||
| ].join('\n'); | ||
| }; | ||
@@ -584,0 +641,0 @@ /** |
+4
-2
| { | ||
| "name": "@amritk/generate-validators", | ||
| "version": "0.8.0", | ||
| "version": "0.9.0", | ||
| "description": "Generate TypeScript validation functions from JSON Schemas.", | ||
@@ -36,3 +36,3 @@ "module": "./dist/index.js", | ||
| "test": "NODE_ENV=production vitest run --root ../.. generate-validators", | ||
| "bench": "bun run ./bench/run.ts" | ||
| "bench": "bun --conditions development ./bench/run.ts" | ||
| }, | ||
@@ -53,2 +53,3 @@ "imports": { | ||
| "devDependencies": { | ||
| "@ryoppippi/unplugin-typia": "^2.6.5", | ||
| "@scalar/openapi-parser": "^0.26.1", | ||
@@ -58,4 +59,5 @@ "@sinclair/typebox": "^0.34.49", | ||
| "ajv-formats": "^3.0.1", | ||
| "typia": "^12.1.1", | ||
| "zod": "^4.4.3" | ||
| } | ||
| } |
+27
-21
@@ -98,29 +98,35 @@ <div align="center"> | ||
| Generated validators are straight-line, monomorphic TypeScript with no generic | ||
| dispatch. On the happy path they run a single allocation-free boolean guard — a | ||
| pure `&&` chain of `typeof` checks (plus an `Object.keys().length` count when an | ||
| object is closed with `additionalProperties: false`) — and only fall back to the | ||
| error-collecting body when something is actually wrong. That makes a valid-input | ||
| check as cheap as TypeBox's compiled checker while still emitting full | ||
| JSON-Pointer errors for invalid input, and emitting the validator stays far | ||
| cheaper than compiling a schema at startup. Measured on Bun 1.3 (Linux x64), | ||
| validating valid input at steady state: | ||
| dispatch. The exported `validateX` is split into a hot and a cold half: on the | ||
| happy path it runs a single allocation-free boolean guard — a pure `&&` chain of | ||
| `typeof` checks (plus an `Object.keys().length` count when an object is closed | ||
| with `additionalProperties: false`) — and `return true`s straight away, only | ||
| calling a separate error-collecting function when something is actually wrong. | ||
| Keeping the hot function tiny lets V8 optimise it aggressively, so a valid-input | ||
| check beats every other library measured — including the build-time transformer | ||
| typia — while still emitting full JSON-Pointer errors for invalid input, and | ||
| emitting the validator stays far cheaper than compiling a schema at startup. | ||
| Measured on Bun 1.3 (Linux x64), validating valid input at steady state: | ||
| | schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod | | ||
| |:--|--:|--:|--:|--:| | ||
| | small (4 fields) | **~37M** ops/s | ~10M ops/s | ~4.9M ops/s | ~2.0M ops/s | | ||
| | order (nested + array) | **~11M** ops/s | ~3.7M ops/s | ~2.0M ops/s | ~0.5M ops/s | | ||
| | assert-loose | **~67M** ops/s | ~40M ops/s | ~57M ops/s | ~3.2M ops/s | | ||
| | assert-strict | **~47M** ops/s | ~19M ops/s | ~36M ops/s | ~1.3M ops/s | | ||
| | schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod | | ||
| |:--|--:|--:|--:|--:|--:| | ||
| | small (4 fields) | **~22M** ops/s | ~4.2M ops/s | ~7.0M ops/s | ~4.0M ops/s | ~1.8M ops/s | | ||
| | order (nested + array) | **~6.9M** ops/s | ~1.7M ops/s | ~2.5M ops/s | ~1.7M ops/s | ~0.4M ops/s | | ||
| | assert-loose | **~110M** ops/s | ~100M ops/s | ~31M ops/s | ~41M ops/s | ~3.2M ops/s | | ||
| | assert-strict | **~98M** ops/s | ~82M ops/s | ~13M ops/s | ~28M ops/s | ~1.1M ops/s | | ||
| The `assert-loose` / `assert-strict` rows are the exact shape used by | ||
| [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks) | ||
| (seven scalar roots plus a nested object); the boolean guard lets mjst edge out | ||
| TypeBox's compiled checker on both, with and without `additionalProperties: | ||
| false`. | ||
| (seven scalar roots plus a nested object); the boolean guard lets mjst edge past | ||
| typia on both, with and without `additionalProperties: false`. (typia and | ||
| TypeBox still win the *invalid* path, where they bail on the first error rather | ||
| than collecting a full error list.) | ||
| Preparing a validator costs ~0.1 ms for mjst codegen and ~0.05–0.12 ms for a | ||
| TypeBox `TypeCompiler` compile, versus ~8–10 ms for an Ajv compile. All four | ||
| libraries agree on every verdict; parity is asserted before timing (TypeBox is | ||
| given uuid/email format checkers so every library does the same work). | ||
| Micro-benchmark figures vary by machine and runtime — reproduce with: | ||
| TypeBox `TypeCompiler` compile, versus ~8–10 ms for an Ajv compile. Every library | ||
| agrees on every verdict; parity is asserted before timing (TypeBox is given | ||
| uuid/email format checkers so every library does the same work). Each library is | ||
| timed in an isolated process over a pool of distinct inputs, reporting the median | ||
| of many trials — so the optimiser can't hoist or eliminate the work and the | ||
| numbers stay reproducible. Micro-benchmark figures vary by machine and runtime — | ||
| reproduce with: | ||
@@ -127,0 +133,0 @@ ```bash |
62833
6.4%1184
5.06%149
4.2%7
40%