@amritk/generate-validators 0.7.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,20 +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, so they validate as fast as an Ajv-compiled function once emitted
99
- and emitting them is far cheaper than compiling a schema at startup. Measured
100
- on Bun 1.3 (Linux x64), validating valid input at steady state:
101
-
102
- | schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
103
- |:--|--:|--:|--:|--:|
104
- | small (4 fields) | **~9.5M** ops/s | ~9.3M ops/s | ~4.8M ops/s | ~1.7M ops/s |
105
- | order (nested + array) | **~3.7M** ops/s | ~3.5M ops/s | ~2.0M ops/s | ~0.4M ops/s |
106
-
107
- Preparing a validator costs ~0.15–0.20 ms for mjst codegen and ~0.12–0.21 ms for
108
- a TypeBox `TypeCompiler` compile, versus ~13–14 ms for an Ajv compile. All four
109
- libraries agree on every verdict; parity is asserted before timing (TypeBox is
110
- given uuid/email format checkers so every library does the same work).
111
- Micro-benchmark figures vary by machine and runtime reproduce with:
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 |
115
+
116
+ The `assert-loose` / `assert-strict` rows are the exact shape used by
117
+ [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
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.)
122
+
123
+ Preparing a validator costs ~0.1 ms for mjst codegen and ~0.05–0.12 ms for a
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:
112
131
 
113
132
  ```bash
114
133
  bun run bench
@@ -1,7 +1,9 @@
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';
6
+ import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
5
7
  /**
6
8
  * Derives the validator function name from a type name.
7
9
  * e.g. "InfoObject" → "validateInfoObject"
@@ -49,29 +51,51 @@ const wrongTypeCondition = (accessor, type) => {
49
51
  }
50
52
  };
51
53
  const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
54
+ /**
55
+ * Returns the `patternProperties` regex sources, or an empty array when the
56
+ * schema declares none. The keys of `patternProperties` are the patterns.
57
+ */
58
+ const patternPropertySources = (schema) => {
59
+ if (!isSchemaObject(schema) || !('patternProperties' in schema))
60
+ return [];
61
+ const patterns = schema.patternProperties;
62
+ if (typeof patterns !== 'object' || patterns === null)
63
+ return [];
64
+ return Object.keys(patterns);
65
+ };
52
66
  /**
53
67
  * Generates the unknown-key sweep for `additionalProperties: false`, mirroring
54
68
  * the interpreter's behaviour (same error message, one error per extra key).
55
- * The known-keys Set is hoisted to module scope and the sweep uses `for...in`
56
- * the same allocation-free shape Ajv compiles to so the hot path costs one
57
- * Set lookup per key. Schemas that combine it with `patternProperties` are
58
- * skipped: the generator does not evaluate key patterns yet, so rejecting
59
- * every undeclared key would wrongly fail keys the patterns allow.
69
+ * The sweep uses `for...in` the same allocation-free shape Ajv compiles to.
70
+ * The per-key "is this declared" test comes from `unknownKeyCheck`, which
71
+ * inlines `!==` comparisons for small key counts (faster than `Set.has` and
72
+ * allocation-free) and hoists a known-keys `Set` only when the list is long.
73
+ * When the schema also declares `patternProperties`, a key matching any pattern
74
+ * is not "additional": the patterns are compiled once at module scope (the same
75
+ * regex-caching the interpreter does) and a key survives the sweep if it is a
76
+ * known key or matches any pattern.
60
77
  */
61
78
  const generateStrictKeyChecks = (schema, ctx) => {
62
79
  if (!isSchemaObject(schema))
63
80
  return [];
64
81
  if (!hasAdditionalProperties(schema) || schema.additionalProperties !== false)
65
82
  return [];
66
- if ('patternProperties' in schema)
67
- return [];
68
83
  const known = Object.keys(hasProperties(schema) ? schema.properties : {});
69
- const setName = `_knownKeys${ctx.hoisted.length}`;
70
- ctx.hoisted.push(`const ${setName} = new Set(${JSON.stringify(known)})`);
71
84
  const d = ctx.depth;
85
+ const check = unknownKeyCheck(known, `_knownKeys${ctx.hoisted.length}`);
86
+ ctx.hoisted.push(...check.declarations);
87
+ // A key that matches any `patternProperties` regex is allowed, so only keys
88
+ // outside both the known keys and every pattern count as additional.
89
+ const patterns = patternPropertySources(schema);
90
+ let patternGuard = '';
91
+ if (patterns.length > 0) {
92
+ const patternsName = `_patterns${ctx.hoisted.length}`;
93
+ ctx.hoisted.push(`const ${patternsName} = [${patterns.map((p) => `new RegExp(${JSON.stringify(p)})`).join(', ')}]`);
94
+ patternGuard = ` && !${patternsName}.some((re) => re.test(_key${d}))`;
95
+ }
72
96
  return [
73
97
  ` for (const _key${d} in ${ctx.objVar}) {`,
74
- ` if (!${setName}.has(_key${d})) {`,
98
+ ` if (${check.isUnknown(`_key${d}`)}${patternGuard}) {`,
75
99
  ` errors.push({ message: 'must NOT have additional properties', path: \`${ctx.pathPrefix}/\${_key${d}}\` })`,
76
100
  ` }`,
77
101
  ` }`,
@@ -370,6 +394,145 @@ const generatePropertyNameChecks = (nameSchema, suffix) => {
370
394
  return [];
371
395
  return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
372
396
  };
397
+ /**
398
+ * Builds the `&&` conditions that prove a single property is valid, or `null`
399
+ * when the property carries any keyword the slow path enforces beyond a bare
400
+ * type check (pattern, min/max, enum, const, `$ref`, items, x-mjst, …). A `null`
401
+ * makes the whole guard bail so that input still flows through the slow,
402
+ * error-collecting path — the guard only ever returns true for *provably* valid
403
+ * input, never weakening a verdict. `objAcc` is the expression yielding the
404
+ * parent object (already narrowed to a record); `key` indexes into it.
405
+ */
406
+ const guardPropConditions = (key, propSchema, objAcc) => {
407
+ if (!isSchemaObject(propSchema))
408
+ return null;
409
+ // Dotted access (`obj.number`) for identifier keys, bracket access otherwise.
410
+ const raw = safeAccessor(objAcc, key);
411
+ // Anything the slow path enforces past a typeof is cheaper to leave to the
412
+ // slow path than to mirror here, so bail and keep the guard sound.
413
+ if (hasRef(propSchema) ||
414
+ hasEnum(propSchema) ||
415
+ hasConst(propSchema) ||
416
+ hasOneOf(propSchema) ||
417
+ getMjstInstanceOf(propSchema) !== undefined ||
418
+ getMjstPrimitive(propSchema) !== undefined ||
419
+ hasPattern(propSchema) ||
420
+ hasMinLength(propSchema) ||
421
+ hasMaxLength(propSchema) ||
422
+ hasMinimum(propSchema) ||
423
+ hasMaximum(propSchema) ||
424
+ hasExclusiveMinimum(propSchema) ||
425
+ hasExclusiveMaximum(propSchema) ||
426
+ hasMultipleOf(propSchema) ||
427
+ hasItems(propSchema)) {
428
+ return null;
429
+ }
430
+ if (!hasType(propSchema))
431
+ return null;
432
+ switch (propSchema.type) {
433
+ case 'string':
434
+ return [`typeof ${raw} === 'string'`];
435
+ // mjst treats `integer` like `number` (it never enforces integrality), so a
436
+ // `typeof === 'number'` guard matches the slow path's verdict exactly.
437
+ case 'number':
438
+ case 'integer':
439
+ return [`typeof ${raw} === 'number'`];
440
+ case 'boolean':
441
+ return [`typeof ${raw} === 'boolean'`];
442
+ case 'object':
443
+ // Member access into the nested record is only reached after the shape
444
+ // check ahead of it in the `&&` chain, so the cast is always safe.
445
+ return guardObjectConditions(propSchema, raw, `(${raw} as Record<string, unknown>)`);
446
+ // Arrays need a per-item loop the guard can't express, and any other type
447
+ // (null, multi-type, untyped) is left to the slow path.
448
+ default:
449
+ return null;
450
+ }
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
+ };
480
+ /**
481
+ * Builds the allocation-free boolean guard for an object schema as a list of
482
+ * `&&` conditions, or `null` when the schema can't be proven valid by a cheap
483
+ * expression. The conditions are ordered so every member access is guarded by
484
+ * the object-shape check that precedes it in the `&&` chain.
485
+ *
486
+ * The guard only handles the happy path: every declared property must be
487
+ * required and a bare-typed scalar or a likewise-guardable nested object. Any
488
+ * optional property, object-level constraint the slow path enforces
489
+ * (`patternProperties`, `propertyNames`, `dependentRequired`, an
490
+ * `additionalProperties` *schema*), or unguardable property makes it bail, and
491
+ * the validator falls back to its full error-collecting body.
492
+ */
493
+ const guardObjectConditions = (schema, raw, objAcc) => {
494
+ if (!isObjectSchema(schema))
495
+ return null;
496
+ if (hasDependentRequired(schema) || hasPropertyNames(schema))
497
+ return null;
498
+ if (isSchemaObject(schema) && 'patternProperties' in schema)
499
+ return null;
500
+ let strict = false;
501
+ if (hasAdditionalProperties(schema)) {
502
+ // Only `additionalProperties: false` is guardable (via the key-count trick
503
+ // below); an additional-properties *schema* needs per-key validation.
504
+ if (schema.additionalProperties === false)
505
+ strict = true;
506
+ else
507
+ return null;
508
+ }
509
+ const required = new Set(hasRequired(schema) ? schema.required : []);
510
+ const properties = hasProperties(schema) ? schema.properties : {};
511
+ const keys = Object.keys(properties);
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}`];
516
+ for (const key of keys) {
517
+ // An optional property would need an `=== undefined ||` branch and breaks
518
+ // the key-count trick, so the guard only covers all-required objects.
519
+ if (!required.has(key))
520
+ return null;
521
+ const propConditions = guardPropConditions(key, properties[key], objAcc);
522
+ if (propConditions === null)
523
+ return null;
524
+ conditions.push(...propConditions);
525
+ }
526
+ if (strict) {
527
+ // `additionalProperties: false` with every declared property required: once
528
+ // the typeof checks confirm each key is present, an exact key count proves
529
+ // there are no extras — TypeBox's trick, with no loop and no Set.
530
+ if (!keys.every((key) => required.has(key)))
531
+ return null;
532
+ conditions.push(`Object.keys(${objAcc}).length === ${keys.length}`);
533
+ }
534
+ return conditions;
535
+ };
373
536
  /**
374
537
  * Generates a validator function body for an object schema, checking each
375
538
  * property's presence and type and collecting all errors.
@@ -421,18 +584,54 @@ const generateObjectValidator = (schema, typeName, suffix) => {
421
584
  // Hoisted statements (e.g. known-keys Sets) come first so every call of the
422
585
  // validator reuses them instead of rebuilding them.
423
586
  const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
424
- return [
425
- `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
587
+ // A pure boolean guard for the happy path: when every property is present and
588
+ // well-typed (and, for strict objects, there are no extras) it returns true
589
+ // without allocating an `errors` array or touching the slow path. It returns
590
+ // true only for provably valid input; anything it can't prove cheaply falls
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.
594
+ const guard = guardObjectConditions(schema, 'input', 'obj');
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 => {`,
600
+ ` const obj = input as Record<string, unknown>`,
426
601
  ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
427
602
  ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
428
603
  ` }`,
429
604
  ``,
430
605
  ` const errors: ValidationError[] = []`,
431
- ` const obj = input as Record<string, unknown>`,
432
606
  body,
433
607
  ` return errors.length > 0 ? { valid: false, errors } : true`,
434
608
  `}`,
435
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');
436
635
  };
437
636
  /**
438
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.7.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"
@@ -46,13 +46,15 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "json-schema-typed": "^8.0.1",
49
- "@amritk/helpers": "0.9.0"
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
  }