@amritk/generate-validators 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,33 +95,39 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
95
95
  ## Benchmarks
96
96
 
97
97
  Generated validators are straight-line, monomorphic TypeScript with no generic
98
- dispatch. On the happy path they run a single allocation-free boolean guard a
99
- pure `&&` chain of `typeof` checks (plus an `Object.keys().length` count when an
100
- object is closed with `additionalProperties: false`) and only fall back to the
101
- error-collecting body when something is actually wrong. That makes a valid-input
102
- check as cheap as TypeBox's compiled checker while still emitting full
103
- JSON-Pointer errors for invalid input, and emitting the validator stays far
104
- cheaper than compiling a schema at startup. Measured on Bun 1.3 (Linux x64),
105
- validating valid input at steady state:
106
-
107
- | schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
108
- |:--|--:|--:|--:|--:|
109
- | small (4 fields) | **~37M** ops/s | ~10M ops/s | ~4.9M ops/s | ~2.0M ops/s |
110
- | order (nested + array) | **~11M** ops/s | ~3.7M ops/s | ~2.0M ops/s | ~0.5M ops/s |
111
- | assert-loose | **~67M** ops/s | ~40M ops/s | ~57M ops/s | ~3.2M ops/s |
112
- | assert-strict | **~47M** ops/s | ~19M ops/s | ~36M ops/s | ~1.3M ops/s |
98
+ dispatch. The exported `validateX` is split into a hot and a cold half: on the
99
+ happy path it runs a single allocation-free boolean guard a pure `&&` chain of
100
+ `typeof` checks (plus an `Object.keys().length` count when an object is closed
101
+ with `additionalProperties: false`) and `return true`s straight away, only
102
+ calling a separate error-collecting function when something is actually wrong.
103
+ Keeping the hot function tiny lets V8 optimise it aggressively, so a valid-input
104
+ check beats every other library measured including the build-time transformer
105
+ typia while still emitting full JSON-Pointer errors for invalid input, and
106
+ emitting the validator stays far cheaper than compiling a schema at startup.
107
+ Measured on Bun 1.3 (Linux x64), validating valid input at steady state:
108
+
109
+ | schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
110
+ |:--|--:|--:|--:|--:|--:|
111
+ | small (4 fields) | **~22M** ops/s | ~4.2M ops/s | ~7.0M ops/s | ~4.0M ops/s | ~1.8M ops/s |
112
+ | order (nested + array) | **~6.9M** ops/s | ~1.7M ops/s | ~2.5M ops/s | ~1.7M ops/s | ~0.4M ops/s |
113
+ | assert-loose | **~110M** ops/s | ~100M ops/s | ~31M ops/s | ~41M ops/s | ~3.2M ops/s |
114
+ | assert-strict | **~98M** ops/s | ~82M ops/s | ~13M ops/s | ~28M ops/s | ~1.1M ops/s |
113
115
 
114
116
  The `assert-loose` / `assert-strict` rows are the exact shape used by
115
117
  [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
116
- (seven scalar roots plus a nested object); the boolean guard lets mjst edge out
117
- TypeBox's compiled checker on both, with and without `additionalProperties:
118
- false`.
118
+ (seven scalar roots plus a nested object); the boolean guard lets mjst edge past
119
+ typia on both, with and without `additionalProperties: false`. (typia and
120
+ TypeBox still win the *invalid* path, where they bail on the first error rather
121
+ than collecting a full error list.)
119
122
 
120
123
  Preparing a validator costs ~0.1 ms for mjst codegen and ~0.05–0.12 ms for a
121
- TypeBox `TypeCompiler` compile, versus ~8–10 ms for an Ajv compile. All four
122
- libraries agree on every verdict; parity is asserted before timing (TypeBox is
123
- given uuid/email format checkers so every library does the same work).
124
- Micro-benchmark figures vary by machine and runtime reproduce with:
124
+ TypeBox `TypeCompiler` compile, versus ~8–10 ms for an Ajv compile. Every library
125
+ agrees on every verdict; parity is asserted before timing (TypeBox is given
126
+ uuid/email format checkers so every library does the same work). Each library is
127
+ timed in an isolated process over a pool of distinct inputs, reporting the median
128
+ of many trials — so the optimiser can't hoist or eliminate the work and the
129
+ numbers stay reproducible. Micro-benchmark figures vary by machine and runtime —
130
+ reproduce with:
125
131
 
126
132
  ```bash
127
133
  bun run bench
@@ -1,6 +1,7 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
3
  import { refToName } from '@amritk/helpers/ref-to-name';
4
+ import { safeAccessor } from '@amritk/helpers/safe-accessor';
4
5
  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';
5
6
  import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
6
7
  /**
@@ -405,7 +406,8 @@ const generatePropertyNameChecks = (nameSchema, suffix) => {
405
406
  const guardPropConditions = (key, propSchema, objAcc) => {
406
407
  if (!isSchemaObject(propSchema))
407
408
  return null;
408
- const raw = `${objAcc}[${JSON.stringify(key)}]`;
409
+ // Dotted access (`obj.number`) for identifier keys, bracket access otherwise.
410
+ const raw = safeAccessor(objAcc, key);
409
411
  // Anything the slow path enforces past a typeof is cheaper to leave to the
410
412
  // slow path than to mirror here, so bail and keep the guard sound.
411
413
  if (hasRef(propSchema) ||
@@ -447,6 +449,34 @@ const guardPropConditions = (key, propSchema, objAcc) => {
447
449
  return null;
448
450
  }
449
451
  };
452
+ /** A property key an array carries with a non-`undefined` value: `length`, or a
453
+ * canonical array index. A required prop on one of these can't be used to rule
454
+ * out arrays (an array's `length` is a number, an index can be anything). */
455
+ const ARRAY_INDEX_KEY = /^(0|[1-9]\d*)$/;
456
+ /** Schema types whose guard is a `typeof` check `typeof undefined` never passes. */
457
+ const TYPEOF_CHECKABLE_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object']);
458
+ /**
459
+ * Whether some required, typeof-guarded property proves the value can't be an
460
+ * array — letting the object shape-check drop its `!Array.isArray(...)` term. An
461
+ * array indexed by a normal key yields `undefined` (or an inherited method),
462
+ * which no `typeof === 'string' | 'number' | 'boolean' | 'object'` accepts, so
463
+ * that field check already rejects arrays. Keys an array does carry a real value
464
+ * for (`length`, numeric indices) are excluded, since those could slip through.
465
+ */
466
+ const arrayRejectedByRequiredProp = (keys, required, properties) => {
467
+ for (const key of keys) {
468
+ if (!required.has(key) || key === 'length' || ARRAY_INDEX_KEY.test(key))
469
+ continue;
470
+ const propSchema = properties[key];
471
+ if (propSchema !== undefined &&
472
+ isSchemaObject(propSchema) &&
473
+ hasType(propSchema) &&
474
+ TYPEOF_CHECKABLE_TYPES.has(propSchema.type)) {
475
+ return true;
476
+ }
477
+ }
478
+ return false;
479
+ };
450
480
  /**
451
481
  * Builds the allocation-free boolean guard for an object schema as a list of
452
482
  * `&&` conditions, or `null` when the schema can't be proven valid by a cheap
@@ -479,7 +509,10 @@ const guardObjectConditions = (schema, raw, objAcc) => {
479
509
  const required = new Set(hasRequired(schema) ? schema.required : []);
480
510
  const properties = hasProperties(schema) ? schema.properties : {};
481
511
  const keys = Object.keys(properties);
482
- const conditions = [`typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})`];
512
+ // The object shape-check only needs `!Array.isArray` when no required field
513
+ // check would already reject an array (see `arrayRejectedByRequiredProp`).
514
+ const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? '' : ` && !Array.isArray(${raw})`;
515
+ const conditions = [`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`];
483
516
  for (const key of keys) {
484
517
  // An optional property would need an `=== undefined ||` branch and breaks
485
518
  // the key-count trick, so the guard only covers all-required objects.
@@ -553,19 +586,18 @@ const generateObjectValidator = (schema, typeName, suffix) => {
553
586
  const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
554
587
  // A pure boolean guard for the happy path: when every property is present and
555
588
  // well-typed (and, for strict objects, there are no extras) it returns true
556
- // without allocating an `errors` array or walking the slow path. It returns
589
+ // without allocating an `errors` array or touching the slow path. It returns
557
590
  // true only for provably valid input; anything it can't prove cheaply falls
558
- // through to the error-collecting body below, which produces the same verdict
559
- // and full JSON-Pointer errors. Schemas with constraints the guard can't
560
- // express produce no guard at all (`null`), leaving behaviour unchanged.
591
+ // through to the error-collecting path, which produces the same verdict and
592
+ // full JSON-Pointer errors. Schemas with constraints the guard can't express
593
+ // produce no guard at all (`null`), leaving behaviour unchanged.
561
594
  const guard = guardObjectConditions(schema, 'input', 'obj');
562
- const guardBlock = guard
563
- ? [` if (`, guard.map((condition) => ` ${condition}`).join(' &&\n'), ` ) {`, ` return true`, ` }`, ``]
564
- : [];
565
- return [
566
- `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
595
+ // The cold, error-collecting body. When there's a guard this is a separate
596
+ // (unexported) function reached only on failure; the hot path never enters it
597
+ // unless input is actually invalid, so its size never costs the happy path.
598
+ const collectBody = (name, exported) => [
599
+ `${exported ? 'export ' : ''}const ${name} = (input: unknown, _path = ''): ValidationResult => {`,
567
600
  ` const obj = input as Record<string, unknown>`,
568
- ...guardBlock,
569
601
  ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
570
602
  ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
571
603
  ` }`,
@@ -575,6 +607,31 @@ const generateObjectValidator = (schema, typeName, suffix) => {
575
607
  ` return errors.length > 0 ? { valid: false, errors } : true`,
576
608
  `}`,
577
609
  ].join('\n');
610
+ // No guard: the exported validator is the error-collecting function itself.
611
+ if (!guard) {
612
+ return `${hoistedBlock}${collectBody(vName, true)}`;
613
+ }
614
+ // With a guard, keep the happy path inside the exported function — the guard
615
+ // is inlined as an early `return true`, so a valid input never pays an extra
616
+ // call — and move only the cold, error-collecting body into a separate
617
+ // (unexported) function. That keeps `validateX` itself tiny (guard + a single
618
+ // tail call) so V8 optimises it well, without the giant error body bloating
619
+ // the hot path. The exported `(input, _path?) => ValidationResult` contract
620
+ // is unchanged.
621
+ const collectName = `${vName}Errors`;
622
+ return [
623
+ `${hoistedBlock}${collectBody(collectName, false)}`,
624
+ ``,
625
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
626
+ ` const obj = input as Record<string, unknown>`,
627
+ ` if (`,
628
+ guard.map((condition) => ` ${condition}`).join(' &&\n'),
629
+ ` ) {`,
630
+ ` return true`,
631
+ ` }`,
632
+ ` return ${collectName}(input, _path)`,
633
+ `}`,
634
+ ].join('\n');
578
635
  };
579
636
  /**
580
637
  * Generates a validator function for a non-object schema (primitive, array, enum, $ref).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
34
34
  "types:check": "tsgo -p . --noEmit",
35
35
  "test": "NODE_ENV=production vitest run --root ../.. generate-validators",
36
- "bench": "bun run ./bench/run.ts"
36
+ "bench": "bun --conditions development ./bench/run.ts"
37
37
  },
38
38
  "imports": {
39
39
  "#generators/*": "./src/generators/*.ts"
@@ -49,10 +49,12 @@
49
49
  "@amritk/helpers": "0.10.0"
50
50
  },
51
51
  "devDependencies": {
52
+ "@ryoppippi/unplugin-typia": "^2.6.5",
52
53
  "@scalar/openapi-parser": "^0.26.1",
53
54
  "@sinclair/typebox": "^0.34.49",
54
55
  "ajv": "^8.17.1",
55
56
  "ajv-formats": "^3.0.1",
57
+ "typia": "^12.1.1",
56
58
  "zod": "^4.4.3"
57
59
  }
58
60
  }