@amritk/generate-validators 0.7.0 → 0.8.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,17 +95,30 @@ 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:
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:
101
106
 
102
107
  | schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
103
108
  |:--|--:|--:|--:|--:|
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
+ | 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 |
113
+
114
+ The `assert-loose` / `assert-strict` rows are the exact shape used by
115
+ [`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`.
119
+
120
+ 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
109
122
  libraries agree on every verdict; parity is asserted before timing (TypeBox is
110
123
  given uuid/email format checkers so every library does the same work).
111
124
  Micro-benchmark figures vary by machine and runtime — reproduce with:
@@ -2,6 +2,7 @@ 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
4
  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
+ import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
5
6
  /**
6
7
  * Derives the validator function name from a type name.
7
8
  * e.g. "InfoObject" → "validateInfoObject"
@@ -49,29 +50,51 @@ const wrongTypeCondition = (accessor, type) => {
49
50
  }
50
51
  };
51
52
  const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
53
+ /**
54
+ * Returns the `patternProperties` regex sources, or an empty array when the
55
+ * schema declares none. The keys of `patternProperties` are the patterns.
56
+ */
57
+ const patternPropertySources = (schema) => {
58
+ if (!isSchemaObject(schema) || !('patternProperties' in schema))
59
+ return [];
60
+ const patterns = schema.patternProperties;
61
+ if (typeof patterns !== 'object' || patterns === null)
62
+ return [];
63
+ return Object.keys(patterns);
64
+ };
52
65
  /**
53
66
  * Generates the unknown-key sweep for `additionalProperties: false`, mirroring
54
67
  * 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.
68
+ * The sweep uses `for...in` the same allocation-free shape Ajv compiles to.
69
+ * The per-key "is this declared" test comes from `unknownKeyCheck`, which
70
+ * inlines `!==` comparisons for small key counts (faster than `Set.has` and
71
+ * allocation-free) and hoists a known-keys `Set` only when the list is long.
72
+ * When the schema also declares `patternProperties`, a key matching any pattern
73
+ * is not "additional": the patterns are compiled once at module scope (the same
74
+ * regex-caching the interpreter does) and a key survives the sweep if it is a
75
+ * known key or matches any pattern.
60
76
  */
61
77
  const generateStrictKeyChecks = (schema, ctx) => {
62
78
  if (!isSchemaObject(schema))
63
79
  return [];
64
80
  if (!hasAdditionalProperties(schema) || schema.additionalProperties !== false)
65
81
  return [];
66
- if ('patternProperties' in schema)
67
- return [];
68
82
  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
83
  const d = ctx.depth;
84
+ const check = unknownKeyCheck(known, `_knownKeys${ctx.hoisted.length}`);
85
+ ctx.hoisted.push(...check.declarations);
86
+ // A key that matches any `patternProperties` regex is allowed, so only keys
87
+ // outside both the known keys and every pattern count as additional.
88
+ const patterns = patternPropertySources(schema);
89
+ let patternGuard = '';
90
+ if (patterns.length > 0) {
91
+ const patternsName = `_patterns${ctx.hoisted.length}`;
92
+ ctx.hoisted.push(`const ${patternsName} = [${patterns.map((p) => `new RegExp(${JSON.stringify(p)})`).join(', ')}]`);
93
+ patternGuard = ` && !${patternsName}.some((re) => re.test(_key${d}))`;
94
+ }
72
95
  return [
73
96
  ` for (const _key${d} in ${ctx.objVar}) {`,
74
- ` if (!${setName}.has(_key${d})) {`,
97
+ ` if (${check.isUnknown(`_key${d}`)}${patternGuard}) {`,
75
98
  ` errors.push({ message: 'must NOT have additional properties', path: \`${ctx.pathPrefix}/\${_key${d}}\` })`,
76
99
  ` }`,
77
100
  ` }`,
@@ -370,6 +393,113 @@ const generatePropertyNameChecks = (nameSchema, suffix) => {
370
393
  return [];
371
394
  return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
372
395
  };
396
+ /**
397
+ * Builds the `&&` conditions that prove a single property is valid, or `null`
398
+ * when the property carries any keyword the slow path enforces beyond a bare
399
+ * type check (pattern, min/max, enum, const, `$ref`, items, x-mjst, …). A `null`
400
+ * makes the whole guard bail so that input still flows through the slow,
401
+ * error-collecting path — the guard only ever returns true for *provably* valid
402
+ * input, never weakening a verdict. `objAcc` is the expression yielding the
403
+ * parent object (already narrowed to a record); `key` indexes into it.
404
+ */
405
+ const guardPropConditions = (key, propSchema, objAcc) => {
406
+ if (!isSchemaObject(propSchema))
407
+ return null;
408
+ const raw = `${objAcc}[${JSON.stringify(key)}]`;
409
+ // Anything the slow path enforces past a typeof is cheaper to leave to the
410
+ // slow path than to mirror here, so bail and keep the guard sound.
411
+ if (hasRef(propSchema) ||
412
+ hasEnum(propSchema) ||
413
+ hasConst(propSchema) ||
414
+ hasOneOf(propSchema) ||
415
+ getMjstInstanceOf(propSchema) !== undefined ||
416
+ getMjstPrimitive(propSchema) !== undefined ||
417
+ hasPattern(propSchema) ||
418
+ hasMinLength(propSchema) ||
419
+ hasMaxLength(propSchema) ||
420
+ hasMinimum(propSchema) ||
421
+ hasMaximum(propSchema) ||
422
+ hasExclusiveMinimum(propSchema) ||
423
+ hasExclusiveMaximum(propSchema) ||
424
+ hasMultipleOf(propSchema) ||
425
+ hasItems(propSchema)) {
426
+ return null;
427
+ }
428
+ if (!hasType(propSchema))
429
+ return null;
430
+ switch (propSchema.type) {
431
+ case 'string':
432
+ return [`typeof ${raw} === 'string'`];
433
+ // mjst treats `integer` like `number` (it never enforces integrality), so a
434
+ // `typeof === 'number'` guard matches the slow path's verdict exactly.
435
+ case 'number':
436
+ case 'integer':
437
+ return [`typeof ${raw} === 'number'`];
438
+ case 'boolean':
439
+ return [`typeof ${raw} === 'boolean'`];
440
+ case 'object':
441
+ // Member access into the nested record is only reached after the shape
442
+ // check ahead of it in the `&&` chain, so the cast is always safe.
443
+ return guardObjectConditions(propSchema, raw, `(${raw} as Record<string, unknown>)`);
444
+ // Arrays need a per-item loop the guard can't express, and any other type
445
+ // (null, multi-type, untyped) is left to the slow path.
446
+ default:
447
+ return null;
448
+ }
449
+ };
450
+ /**
451
+ * Builds the allocation-free boolean guard for an object schema as a list of
452
+ * `&&` conditions, or `null` when the schema can't be proven valid by a cheap
453
+ * expression. The conditions are ordered so every member access is guarded by
454
+ * the object-shape check that precedes it in the `&&` chain.
455
+ *
456
+ * The guard only handles the happy path: every declared property must be
457
+ * required and a bare-typed scalar or a likewise-guardable nested object. Any
458
+ * optional property, object-level constraint the slow path enforces
459
+ * (`patternProperties`, `propertyNames`, `dependentRequired`, an
460
+ * `additionalProperties` *schema*), or unguardable property makes it bail, and
461
+ * the validator falls back to its full error-collecting body.
462
+ */
463
+ const guardObjectConditions = (schema, raw, objAcc) => {
464
+ if (!isObjectSchema(schema))
465
+ return null;
466
+ if (hasDependentRequired(schema) || hasPropertyNames(schema))
467
+ return null;
468
+ if (isSchemaObject(schema) && 'patternProperties' in schema)
469
+ return null;
470
+ let strict = false;
471
+ if (hasAdditionalProperties(schema)) {
472
+ // Only `additionalProperties: false` is guardable (via the key-count trick
473
+ // below); an additional-properties *schema* needs per-key validation.
474
+ if (schema.additionalProperties === false)
475
+ strict = true;
476
+ else
477
+ return null;
478
+ }
479
+ const required = new Set(hasRequired(schema) ? schema.required : []);
480
+ const properties = hasProperties(schema) ? schema.properties : {};
481
+ const keys = Object.keys(properties);
482
+ const conditions = [`typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})`];
483
+ for (const key of keys) {
484
+ // An optional property would need an `=== undefined ||` branch and breaks
485
+ // the key-count trick, so the guard only covers all-required objects.
486
+ if (!required.has(key))
487
+ return null;
488
+ const propConditions = guardPropConditions(key, properties[key], objAcc);
489
+ if (propConditions === null)
490
+ return null;
491
+ conditions.push(...propConditions);
492
+ }
493
+ if (strict) {
494
+ // `additionalProperties: false` with every declared property required: once
495
+ // the typeof checks confirm each key is present, an exact key count proves
496
+ // there are no extras — TypeBox's trick, with no loop and no Set.
497
+ if (!keys.every((key) => required.has(key)))
498
+ return null;
499
+ conditions.push(`Object.keys(${objAcc}).length === ${keys.length}`);
500
+ }
501
+ return conditions;
502
+ };
373
503
  /**
374
504
  * Generates a validator function body for an object schema, checking each
375
505
  * property's presence and type and collecting all errors.
@@ -421,14 +551,26 @@ const generateObjectValidator = (schema, typeName, suffix) => {
421
551
  // Hoisted statements (e.g. known-keys Sets) come first so every call of the
422
552
  // validator reuses them instead of rebuilding them.
423
553
  const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
554
+ // A pure boolean guard for the happy path: when every property is present and
555
+ // 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
557
+ // 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.
561
+ const guard = guardObjectConditions(schema, 'input', 'obj');
562
+ const guardBlock = guard
563
+ ? [` if (`, guard.map((condition) => ` ${condition}`).join(' &&\n'), ` ) {`, ` return true`, ` }`, ``]
564
+ : [];
424
565
  return [
425
566
  `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
567
+ ` const obj = input as Record<string, unknown>`,
568
+ ...guardBlock,
426
569
  ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
427
570
  ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
428
571
  ` }`,
429
572
  ``,
430
573
  ` const errors: ValidationError[] = []`,
431
- ` const obj = input as Record<string, unknown>`,
432
574
  body,
433
575
  ` return errors.length > 0 ? { valid: false, errors } : true`,
434
576
  `}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -46,7 +46,7 @@
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
52
  "@scalar/openapi-parser": "^0.26.1",