@amritk/generate-validators 0.11.5 → 0.11.7

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
@@ -92,6 +92,27 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
92
92
 
93
93
  ---
94
94
 
95
+ ## Semantics
96
+
97
+ Generated validators track the `@amritk/runtime-validators` interpreter. Array
98
+ items are validated in full — an item's type, `$ref`, nested `properties` /
99
+ `required`, and scalar constraints (`minLength`, `minimum`, …) are all enforced,
100
+ recursing to any depth — and the boolean guard (`isX`) reaches the identical
101
+ verdict. Validating array item *contents* costs throughput proportional to the
102
+ per-item work (a bare `string[]` is free; a closed object with several fields is
103
+ meaningfully slower), which is why array-heavy schemas validate more slowly than
104
+ scalar/object ones.
105
+
106
+ One divergence is worth calling out: **`NaN` satisfies a constrained number.**
107
+ Because the numeric bound checks are the exact negation of the error condition
108
+ (e.g. `!(x < minimum)`), and every comparison against `NaN` is `false`, a `NaN`
109
+ passes `minimum`/`maximum`/`exclusive*`/`multipleOf`. This matches the interpreter
110
+ but differs from validators (e.g. Ajv) that reject `NaN` for `type: "number"`.
111
+ `NaN` never appears in parsed JSON; guard against it upstream if your values can
112
+ be non-JSON.
113
+
114
+ ---
115
+
95
116
  ## Benchmarks
96
117
 
97
118
  Generated validators are straight-line, monomorphic TypeScript with no generic
@@ -108,20 +129,21 @@ Measured on Bun 1.3 (Linux x64), validating valid input at steady state:
108
129
 
109
130
  | schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
110
131
  |:--|--:|--:|--:|--:|--:|
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 |
132
+ | small (4 fields) | **~48M** ops/s | ~5M ops/s | ~10.5M ops/s | ~5.3M ops/s | ~2M ops/s |
133
+ | order (nested + array) | **~7.8M** ops/s | ~2.2M ops/s | ~3.5M ops/s | ~2.1M ops/s | ~0.5M ops/s |
134
+ | assert-loose | **~184M** ops/s | ~183M ops/s | ~45M ops/s | ~63M ops/s | ~3.8M ops/s |
135
+ | assert-strict | **~162M** ops/s | ~148M ops/s | ~22M ops/s | ~38M ops/s | ~1.3M ops/s |
115
136
 
116
137
  The `assert-loose` / `assert-strict` rows are the exact shape used by
117
138
  [`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 ~810 ms for an Ajv compile. Every library
139
+ (seven scalar roots plus a nested object): the boolean guard puts mjst clearly
140
+ ahead of typia on `assert-strict` (with `additionalProperties: false`) and
141
+ neck-and-neck with it on `assert-loose` the two trade the lead run-to-run
142
+ within noise. (typia and TypeBox still win the *invalid* path, where they bail on
143
+ the first error rather than collecting a full error list.)
144
+
145
+ Preparing a validator costs ~0.30.6 ms for mjst codegen and ~0.05–0.2 ms for a
146
+ TypeBox `TypeCompiler` compile, versus ~9–12 ms for an Ajv compile. Every library
125
147
  agrees on every verdict; parity is asserted before timing (TypeBox is given
126
148
  uuid/email format checkers so every library does the same work). Each library is
127
149
  timed in an isolated process over a pool of distinct inputs, reporting the median
@@ -45,6 +45,35 @@ export const valuesEqual = (a: unknown, b: unknown): boolean => {
45
45
  }
46
46
  return true
47
47
  }
48
+
49
+ /**
50
+ * True when every element of \`arr\` is distinct under structural equality
51
+ * ({@link valuesEqual}). Backs generated \`uniqueItems\` checks whose items may be
52
+ * objects or arrays, where a \`JSON.stringify\` dedupe key would be key-order
53
+ * sensitive and let a reordered-but-equal duplicate (\`{ a: 1, b: 2 }\` vs
54
+ * \`{ b: 2, a: 1 }\`) slip through. A native \`Set\` dedupes the all-primitive case
55
+ * in one linear pass; object/array elements fall back to an exact pairwise
56
+ * structural comparison.
57
+ */
58
+ export const allUnique = (arr: readonly unknown[]): boolean => {
59
+ const len = arr.length
60
+ if (len < 2) return true
61
+ let allPrimitive = true
62
+ for (let i = 0; i < len; i++) {
63
+ const v = arr[i]
64
+ if (v !== null && typeof v === 'object') {
65
+ allPrimitive = false
66
+ break
67
+ }
68
+ }
69
+ if (allPrimitive) return new Set(arr).size === len
70
+ for (let i = 0; i < len; i++) {
71
+ for (let j = i + 1; j < len; j++) {
72
+ if (valuesEqual(arr[i], arr[j])) return false
73
+ }
74
+ }
75
+ return true
76
+ }
48
77
  `;
49
78
  /**
50
79
  * Builds all TypeScript validator files from a JSON Schema by traversing all
@@ -54,7 +54,11 @@ const collectDirectRefs = (value, refs = []) => {
54
54
  // generated files, not inlined by this validator. `collectDirectRefs`
55
55
  // self-guards on non-objects, so a keyword that is a boolean or missing is a
56
56
  // harmless no-op.
57
- const subSchemaMaps = ['properties', 'patternProperties'];
57
+ // `dependencies` (draft-07) is dual-form: a string array (dependentRequired) or
58
+ // a subschema (dependentSchemas). The emitter delegates the schema form via
59
+ // `validateX`, so a `$ref` inside it must be imported; the string-array form is
60
+ // a harmless no-op here (its values are strings, not schemas).
61
+ const subSchemaMaps = ['properties', 'patternProperties', 'dependentSchemas', 'dependencies'];
58
62
  for (const mapKey of subSchemaMaps) {
59
63
  const map = schema[mapKey];
60
64
  if (typeof map === 'object' && map !== null && !Array.isArray(map)) {
@@ -37,11 +37,14 @@ export const generateValidatorFile = (schema, typeName, options) => {
37
37
  const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
38
38
  // `.js` extension so the relative import resolves under Node ESM, not only Bun.
39
39
  let result = `import type { ValidationResult, ValidationError } from './validation-result.js'\n`;
40
- // `const` checks on object/array values call the runtime `valuesEqual` helper.
41
- // Only import it when the generated body actually uses it, so files without a
42
- // structural `const` do not carry an unused import.
43
- if (validatorFunction.includes('valuesEqual(')) {
44
- result += `import { valuesEqual } from './validation-result.js'\n`;
40
+ // Structural `const` checks call the runtime `valuesEqual` helper; structural
41
+ // `uniqueItems` checks call `allUnique`. Both live in `validation-result.js`;
42
+ // import each only when the generated body (validator or boolean guard) uses
43
+ // it, so files that need neither carry no unused import.
44
+ const body = validatorFunction + booleanGuard;
45
+ const runtimeHelpers = ['valuesEqual', 'allUnique'].filter((name) => body.includes(`${name}(`));
46
+ if (runtimeHelpers.length > 0) {
47
+ result += `import { ${runtimeHelpers.join(', ')} } from './validation-result.js'\n`;
45
48
  }
46
49
  for (const imp of refImports) {
47
50
  result += imp + '\n';
@@ -3,7 +3,7 @@ import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extens
3
3
  import { multipleOfFailExpr, multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
4
4
  import { refToName } from '@amritk/helpers/ref-to-name';
5
5
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
6
- import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
6
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
7
7
  import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
8
8
  /**
9
9
  * Derives the validator function name from a type name.
@@ -31,6 +31,43 @@ const constMismatchCondition = (accessor, value) => {
31
31
  }
32
32
  return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
33
33
  };
34
+ const SCALAR_ITEM_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null']);
35
+ /**
36
+ * True when a schema's values are provably JSON scalars — its `type` is present
37
+ * and every listed type is a primitive. Conservative: a `$ref`, a boolean/absent
38
+ * schema, an `object`/`array` type, or a missing `type` all fail this test.
39
+ */
40
+ const schemaIsScalarOnly = (schema) => {
41
+ if (!isSchemaObject(schema))
42
+ return false;
43
+ const t = schema['type'];
44
+ if (t === undefined)
45
+ return false;
46
+ const types = Array.isArray(t) ? t : [t];
47
+ return types.length > 0 && types.every((x) => typeof x === 'string' && SCALAR_ITEM_TYPES.has(x));
48
+ };
49
+ /**
50
+ * True when an array's elements can only be JSON scalars, so a `uniqueItems`
51
+ * check can dedupe by the cheap `JSON.stringify` projection. When items may be
52
+ * objects or arrays this returns false, and the check must instead compare
53
+ * structurally (the `allUnique` runtime helper): `JSON.stringify` is key-order
54
+ * sensitive and would treat `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` as distinct,
55
+ * disagreeing with the interpreter's order-independent deep equality.
56
+ */
57
+ const arrayItemsAreScalarOnly = (schema) => {
58
+ const prefix = schema['prefixItems'];
59
+ if (Array.isArray(prefix)) {
60
+ if (!prefix.every((p) => schemaIsScalarOnly(p)))
61
+ return false;
62
+ // Tuple tail: a closed tuple (`items`/`additionalItems: false`) has no tail;
63
+ // otherwise the tail schema must itself be scalar-only.
64
+ const tail = 'items' in schema ? schema['items'] : schema['additionalItems'];
65
+ if (tail === false)
66
+ return true;
67
+ return schemaIsScalarOnly(tail);
68
+ }
69
+ return schemaIsScalarOnly(schema['items']);
70
+ };
34
71
  /**
35
72
  * Generates the inline condition that is TRUE when a value is the wrong type.
36
73
  */
@@ -68,6 +105,21 @@ const getTypeArray = (schema) => {
68
105
  return schema.type;
69
106
  };
70
107
  const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
108
+ /**
109
+ * Renders a schema-controlled property name as a static error-path segment.
110
+ *
111
+ * The name is appended to a backtick template-literal path (`` `${_path}/…` ``),
112
+ * so two independent escapings apply. First the JSON Pointer escape (`~`→`~0`,
113
+ * `/`→`~1`, `~` first) so a key containing `/` or `~` reads back unambiguously —
114
+ * matching the paths the runtime-validators interpreter emits. Then a
115
+ * template-literal escape of `` ` ``, `\`, and `$`, so a key like `` a`b `` or
116
+ * `${x}` cannot terminate the literal (a build failure) or inject an
117
+ * interpolation (a runtime `ReferenceError` / arbitrary expression).
118
+ */
119
+ const pointerSegment = (key) => key
120
+ .replace(/~/g, '~0')
121
+ .replace(/\//g, '~1')
122
+ .replace(/[\\`$]/g, '\\$&');
71
123
  /**
72
124
  * Returns the `patternProperties` regex sources, or an empty array when the
73
125
  * schema declares none. The keys of `patternProperties` are the patterns.
@@ -146,31 +198,54 @@ const generateMissingRequiredChecks = (schema, ctx) => {
146
198
  * and recursion into inline nested objects.
147
199
  */
148
200
  const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
149
- if (!isSchemaObject(propSchema))
201
+ if (!isSchemaObject(propSchema)) {
202
+ // A boolean `true` (accept-anything) schema carries no shape checks, but a
203
+ // required key must still be present. `false` never validates a present value,
204
+ // which the strict-key / additionalProperties path handles; here we only need
205
+ // to enforce presence for `true`.
206
+ if (isRequired && propSchema === true) {
207
+ const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
208
+ return [
209
+ ` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`,
210
+ ` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`,
211
+ ` }`,
212
+ ];
213
+ }
150
214
  return [];
215
+ }
151
216
  const raw = `${ctx.objVar}[${JSON.stringify(key)}]`;
152
- const path = `\`${ctx.pathPrefix}/${key}\``;
217
+ const path = `\`${ctx.pathPrefix}/${pointerSegment(key)}\``;
153
218
  // Missing-property errors report at the parent object's path. At the root
154
219
  // that is the `_path` parameter itself; inside nested objects it is the
155
220
  // parent's accumulated static path.
156
221
  const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
157
222
  const lines = [];
158
- // $ref — delegate to the imported validator
223
+ // $ref — delegate to the imported validator. Per 2020-12, sibling keywords
224
+ // alongside `$ref` still apply to the same value, so any constraint/combinator
225
+ // siblings (e.g. `{ $ref, minLength: 5 }`) run after the delegation. A bare
226
+ // `{ $ref }` produces no siblings, leaving the output unchanged.
159
227
  if (hasRef(propSchema)) {
160
228
  const ref = propSchema.$ref;
161
229
  const vName = validatorName(refToName(ref, suffix));
230
+ const siblings = [
231
+ ...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx),
232
+ ...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx),
233
+ ];
234
+ const delegate = [
235
+ ` const _r = ${vName}(${raw}, ${path})`,
236
+ ` if (_r !== true) errors.push(..._r.errors)`,
237
+ ...siblings,
238
+ ];
162
239
  if (isRequired) {
163
240
  lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
164
241
  lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
165
242
  lines.push(` } else {`);
166
- lines.push(` const _r = ${vName}(${raw}, ${path})`);
167
- lines.push(` if (_r !== true) errors.push(..._r.errors)`);
243
+ lines.push(...delegate);
168
244
  lines.push(` }`);
169
245
  }
170
246
  else {
171
247
  lines.push(` if (${raw} !== undefined) {`);
172
- lines.push(` const _r = ${vName}(${raw}, ${path})`);
173
- lines.push(` if (_r !== true) errors.push(..._r.errors)`);
248
+ lines.push(...delegate);
174
249
  lines.push(` }`);
175
250
  }
176
251
  return lines;
@@ -310,20 +385,32 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
310
385
  ...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx),
311
386
  ...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx),
312
387
  ];
313
- if (extraLines.length > 0) {
314
- if (!hasType(propSchema) && isRequired) {
315
- // No `type` to anchor a missing-property check, so enforce presence here.
316
- lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
317
- lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
318
- lines.push(` } else {`);
388
+ if (hasType(propSchema)) {
389
+ // Presence was already enforced in the `hasType` branch; only wrap the
390
+ // combinator siblings so they run when the value is present.
391
+ if (extraLines.length > 0) {
392
+ lines.push(` if (${raw} !== undefined) {`);
319
393
  lines.push(...extraLines);
320
394
  lines.push(` }`);
321
395
  }
322
- else {
323
- lines.push(` if (${raw} !== undefined) {`);
396
+ }
397
+ else if (isRequired) {
398
+ // Type-less required property. Presence must be enforced even when the schema
399
+ // contributes no other checks (e.g. `{}` — an accept-anything schema), so a
400
+ // missing required key is still an error. Any extra checks run in the `else`.
401
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
402
+ lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
403
+ if (extraLines.length > 0) {
404
+ lines.push(` } else {`);
324
405
  lines.push(...extraLines);
325
- lines.push(` }`);
326
406
  }
407
+ lines.push(` }`);
408
+ }
409
+ else if (extraLines.length > 0) {
410
+ // Type-less optional property: run any checks only when the value is present.
411
+ lines.push(` if (${raw} !== undefined) {`);
412
+ lines.push(...extraLines);
413
+ lines.push(` }`);
327
414
  }
328
415
  return lines;
329
416
  };
@@ -405,40 +492,59 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
405
492
  lines.push(` }`);
406
493
  }
407
494
  }
408
- // Array with typed items
495
+ // Array items. `$ref` items delegate to the referenced validator. Any other
496
+ // item subschema is validated in full — matching the interpreter — but wrapped
497
+ // in a per-item boolean fast-check (`booleanLeafExpr`): a valid item passes the
498
+ // flat expression and skips the error-collecting body entirely, so the common
499
+ // valid case stays allocation-free (the same hot/cold split the top-level
500
+ // validator uses). This keeps array-heavy throughput close to a bare type check
501
+ // while still fully validating every item. The loop variables carry the nesting
502
+ // depth so item loops can nest (array-of-arrays) without colliding.
409
503
  if (hasItems(propSchema)) {
410
504
  const itemSchema = propSchema.items;
505
+ const iv = `_i${ctx.depth}`;
506
+ const itemPath = `\`${path.slice(1, -1)}/\${${iv}}\``;
411
507
  if (hasRef(itemSchema)) {
412
508
  const vName = validatorName(refToName(itemSchema.$ref, suffix));
413
509
  lines.push(` if (Array.isArray(${raw})) {`);
414
- lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
415
- lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
510
+ lines.push(` for (let ${iv} = 0; ${iv} < ${raw}.length; ${iv}++) {`);
511
+ lines.push(` const _ir = ${vName}(${raw}[${iv}], ${itemPath})`);
416
512
  lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
417
513
  lines.push(` }`);
418
514
  lines.push(` }`);
419
515
  }
420
- else if (hasType(itemSchema)) {
421
- const itemType = itemSchema.type;
422
- const itemWrong = wrongTypeCondition('_item', itemType);
423
- const itemLabel = typeofString(itemType);
424
- if (itemWrong) {
516
+ else if (isSchemaObject(itemSchema)) {
517
+ const itemVar = `_item${ctx.depth}`;
518
+ const detail = generateValueChecks('', itemVar, itemPath, itemSchema, suffix, ctx, true);
519
+ if (detail.length > 0) {
425
520
  lines.push(` if (Array.isArray(${raw})) {`);
426
- lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
427
- lines.push(` const _item = ${raw}[_i]`);
428
- lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
521
+ lines.push(` for (let ${iv} = 0; ${iv} < ${raw}.length; ${iv}++) {`);
522
+ lines.push(` const ${itemVar} = ${raw}[${iv}]`);
523
+ lines.push(...detail.map((l) => ` ${l}`));
429
524
  lines.push(` }`);
430
525
  lines.push(` }`);
431
526
  }
432
527
  }
433
528
  }
434
- // Array length / uniqueness. `uniqueItems` dedupes by a JSON projection — exact
435
- // for primitives (what the type guard also uses); deep-but-key-ordered for
436
- // objects, the same pragmatic trade-off the rest of the generator makes.
529
+ // Array length / uniqueness. `uniqueItems` dedupes scalar items by a cheap
530
+ // `JSON.stringify` projection (exact for primitives, what the type guard also
531
+ // uses), but falls back to the structural `allUnique` helper when items may be
532
+ // objects/arrays — `JSON.stringify` is key-order sensitive and would disagree
533
+ // with the interpreter's order-independent deep equality.
437
534
  if (hasMinItems(propSchema) ||
438
535
  hasMaxItems(propSchema) ||
439
536
  (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) ||
440
537
  isSchemaObject(sp['contains']) ||
441
- Array.isArray(sp['prefixItems'])) {
538
+ Array.isArray(sp['prefixItems']) ||
539
+ (sp['items'] === false && !Array.isArray(sp['prefixItems']))) {
540
+ // `items: false` with no `prefixItems` forbids every element, so the array
541
+ // must be empty. (With `prefixItems`, the tuple block below caps the length
542
+ // instead.) Without this the constraint was silently ignored.
543
+ if (sp['items'] === false && !Array.isArray(sp['prefixItems'])) {
544
+ lines.push(` if (Array.isArray(${raw}) && ${raw}.length > 0) {`);
545
+ lines.push(` errors.push({ message: 'must NOT have more than 0 items', path: ${path} })`);
546
+ lines.push(` }`);
547
+ }
442
548
  if (hasMinItems(propSchema)) {
443
549
  lines.push(` if (Array.isArray(${raw}) && ${raw}.length < ${propSchema.minItems}) {`);
444
550
  lines.push(` errors.push({ message: 'must have at least ${propSchema.minItems} items', path: ${path} })`);
@@ -450,7 +556,10 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
450
556
  lines.push(` }`);
451
557
  }
452
558
  if (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) {
453
- lines.push(` if (Array.isArray(${raw}) && new Set((${raw} as unknown[]).map((_u) => JSON.stringify(_u))).size !== ${raw}.length) {`);
559
+ const dupCond = arrayItemsAreScalarOnly(sp)
560
+ ? `new Set((${raw} as unknown[]).map((_u) => JSON.stringify(_u))).size !== ${raw}.length`
561
+ : `!allUnique(${raw} as unknown[])`;
562
+ lines.push(` if (Array.isArray(${raw}) && ${dupCond}) {`);
454
563
  lines.push(` errors.push({ message: 'must NOT have duplicate items', path: ${path} })`);
455
564
  lines.push(` }`);
456
565
  }
@@ -499,34 +608,49 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
499
608
  };
500
609
  /**
501
610
  * Validates a value located at a *dynamic* key (a `patternProperties` or
502
- * `additionalProperties` schema value) against `propSchema`. Mirrors the
503
- * optional-property branch of {@link generatePropertyChecks} the value is
504
- * always present, so each check is the same `!== undefined`-guarded form — but
505
- * `raw` and `path` are caller-supplied expressions (e.g. `obj[_k]` and
506
- * `` `${_path}/${_k}` ``) so the checks read a runtime key.
611
+ * `additionalProperties` value), an array item, a combinator branch, or a
612
+ * `dependentSchemas` subschema against `propSchema`. `raw` and `path` are
613
+ * caller-supplied expressions (e.g. `obj[_k]` and `` `${_path}/${_k}` ``) so the
614
+ * checks read a runtime location. By default the leaf checks are
615
+ * `!== undefined`-guarded (an absent optional value is valid); pass
616
+ * `required = true` for values that must be present (array items — a sparse hole
617
+ * reads as `undefined` and must fail), which drops that guard. `_key` is unused
618
+ * (the location is fully encoded by `path`) but kept for positional-call parity
619
+ * with the combinator generators.
507
620
  */
508
- const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
621
+ const generateValueChecks = (_key, raw, path, propSchema, suffix, ctx, required = false) => {
509
622
  if (!isSchemaObject(propSchema))
510
623
  return [];
511
624
  const lines = [];
625
+ // Optional values skip validation when absent, so their leaf checks are
626
+ // `!== undefined`-guarded. Array items are unconditionally present — a sparse
627
+ // hole reads as `undefined` and must FAIL its type/const/enum check — so
628
+ // `required` drops the guard.
629
+ const presence = required ? '' : `${raw} !== undefined && `;
512
630
  if (hasRef(propSchema)) {
513
631
  const vName = validatorName(refToName(propSchema.$ref, suffix));
514
- lines.push(` if (${raw} !== undefined) {`);
515
- lines.push(` const _r = ${vName}(${raw}, ${path})`);
516
- lines.push(` if (_r !== true) errors.push(..._r.errors)`);
517
- lines.push(` }`);
632
+ if (required) {
633
+ lines.push(` const _r = ${vName}(${raw}, ${path})`);
634
+ lines.push(` if (_r !== true) errors.push(..._r.errors)`);
635
+ }
636
+ else {
637
+ lines.push(` if (${raw} !== undefined) {`);
638
+ lines.push(` const _r = ${vName}(${raw}, ${path})`);
639
+ lines.push(` if (_r !== true) errors.push(..._r.errors)`);
640
+ lines.push(` }`);
641
+ }
518
642
  return lines;
519
643
  }
520
644
  const instanceOf = getMjstInstanceOf(propSchema);
521
645
  if (instanceOf) {
522
- lines.push(` if (${raw} !== undefined && !(${raw} instanceof ${instanceOf})) {`);
646
+ lines.push(` if (${presence}!(${raw} instanceof ${instanceOf})) {`);
523
647
  lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
524
648
  lines.push(` }`);
525
649
  return lines;
526
650
  }
527
651
  const primitive = getMjstPrimitive(propSchema);
528
652
  if (primitive) {
529
- lines.push(` if (${raw} !== undefined && typeof ${raw} !== "${primitive}") {`);
653
+ lines.push(` if (${presence}typeof ${raw} !== "${primitive}") {`);
530
654
  lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
531
655
  lines.push(` }`);
532
656
  return lines;
@@ -534,7 +658,7 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
534
658
  if (hasConst(propSchema)) {
535
659
  const mismatch = constMismatchCondition(raw, propSchema.const);
536
660
  const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
537
- lines.push(` if (${raw} !== undefined && ${mismatch}) {`);
661
+ lines.push(` if (${presence}${mismatch}) {`);
538
662
  lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
539
663
  lines.push(` }`);
540
664
  return lines;
@@ -542,7 +666,7 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
542
666
  if (hasEnum(propSchema)) {
543
667
  const allowed = JSON.stringify(propSchema.enum);
544
668
  const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
545
- lines.push(` if (${raw} !== undefined && !(${allowed} as unknown[]).includes(${raw})) {`);
669
+ lines.push(` if (${presence}!(${allowed} as unknown[]).includes(${raw})) {`);
546
670
  lines.push(` errors.push({ message: ${JSON.stringify(`must be one of: ${label}`)}, path: ${path} })`);
547
671
  lines.push(` }`);
548
672
  return lines;
@@ -552,7 +676,7 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
552
676
  const wrongType = wrongTypeCondition(raw, t);
553
677
  const typLabel = typeofString(t);
554
678
  if (wrongType) {
555
- lines.push(` if (${raw} !== undefined && (${wrongType})) {`);
679
+ lines.push(` if (${presence}(${wrongType})) {`);
556
680
  lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
557
681
  lines.push(` }`);
558
682
  }
@@ -561,8 +685,20 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
561
685
  // gate on keyword presence + a runtime-type guard, so a type-less subschema
562
686
  // (a combinator branch like `{ required: [...] }` or `{ minItems: 2 }`) is
563
687
  // still validated rather than collapsing to "matches everything".
564
- lines.push(...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx));
565
- lines.push(...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx));
688
+ //
689
+ // This value lives at `path` (a template literal), so anchor the recursion's
690
+ // context there and one nesting level deeper: any nested object/array it emits
691
+ // then builds paths relative to THIS value and mints collision-free variable
692
+ // names, independent of the caller's context. `key` is intentionally dropped
693
+ // (set to `''`) because `path` already locates the value.
694
+ const valueCtx = {
695
+ objVar: ctx.objVar,
696
+ pathPrefix: path.slice(1, -1),
697
+ depth: ctx.depth + 1,
698
+ hoisted: ctx.hoisted,
699
+ };
700
+ lines.push(...generateConstraintChecks('', raw, path, propSchema, suffix, valueCtx));
701
+ lines.push(...generateCombinatorChecks('', raw, path, propSchema, suffix, valueCtx));
566
702
  return lines;
567
703
  };
568
704
  /**
@@ -698,7 +834,10 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
698
834
  return [];
699
835
  const child = {
700
836
  objVar: `_obj${ctx.depth + 1}`,
701
- pathPrefix: `${ctx.pathPrefix}/${key}`,
837
+ // When `key` is empty the value is located AT `ctx.pathPrefix` already (e.g. an
838
+ // inline object reached through a combinator branch or a dynamic-key value), so
839
+ // appending `/${key}` would emit a spurious `//` or trailing `/` in error paths.
840
+ pathPrefix: key === '' ? ctx.pathPrefix : `${ctx.pathPrefix}/${pointerSegment(key)}`,
702
841
  depth: ctx.depth + 1,
703
842
  hoisted: ctx.hoisted,
704
843
  };
@@ -712,6 +851,9 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
712
851
  innerLines.push(...generatePatternAndAdditionalChecks(propSchema, suffix, child));
713
852
  innerLines.push(...generateStrictKeyChecks(propSchema, child));
714
853
  innerLines.push(...generateDependentRequiredChecks(propSchema, child));
854
+ innerLines.push(...generateDependentSchemasChecks(propSchema, suffix, child));
855
+ innerLines.push(...generateDependenciesChecks(propSchema, suffix, child));
856
+ innerLines.push(...generateMinMaxPropertiesChecks(propSchema, child));
715
857
  if (hasPropertyNames(propSchema) && isSchemaObject(propSchema.propertyNames)) {
716
858
  innerLines.push(...generatePropertyNameChecks(propSchema.propertyNames, suffix, child));
717
859
  }
@@ -727,49 +869,29 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
727
869
  ];
728
870
  };
729
871
  /**
730
- * Generates the `propertyNames` loop: every object key is a string, so we apply
731
- * the string-relevant constraints of the subschema (or delegate to a `$ref`'s
732
- * validator). This keeps the generator in step with the interpreter, which runs
733
- * the whole subschema against each key not just the `pattern` form.
872
+ * Generates the `propertyNames` loop: every object key is a string, and the
873
+ * *whole* subschema is validated against each key not just the
874
+ * `pattern`/length/`enum`/`const`/`$ref` subset. Delegating to
875
+ * {@link generateValueChecks} keeps the generator in lockstep with the
876
+ * interpreter, which runs `matchesSchema(nameSchema, key)` per key, so a
877
+ * subschema carrying a combinator, `type`, `multipleOf`, etc. is enforced too.
878
+ * A key is always a present string, so the value checks run in `required` mode
879
+ * (no `!== undefined` guard).
734
880
  */
735
881
  const generatePropertyNameChecks = (nameSchema, suffix, ctx) => {
736
882
  if (!isSchemaObject(nameSchema))
737
883
  return [];
738
884
  const at = `\`${ctx.pathPrefix}/\${_name}\``;
739
- const checks = [];
740
- if (hasRef(nameSchema)) {
741
- const vName = validatorName(refToName(nameSchema.$ref, suffix));
742
- checks.push(` const _nr = ${vName}(_name, ${at})`);
743
- checks.push(` if (_nr !== true) errors.push(..._nr.errors)`);
744
- }
745
- else {
746
- if (hasPattern(nameSchema)) {
747
- const re = escapeRegexPattern(nameSchema.pattern);
748
- const msg = JSON.stringify(`property name must match pattern ${nameSchema.pattern}`);
749
- checks.push(` if (!/${re}/.test(_name)) errors.push({ message: ${msg}, path: ${at} })`);
750
- }
751
- if (hasMinLength(nameSchema)) {
752
- const msg = JSON.stringify(`property name must have at least ${nameSchema.minLength} characters`);
753
- checks.push(` if (_name.length < ${nameSchema.minLength}) errors.push({ message: ${msg}, path: ${at} })`);
754
- }
755
- if (hasMaxLength(nameSchema)) {
756
- const msg = JSON.stringify(`property name must have at most ${nameSchema.maxLength} characters`);
757
- checks.push(` if (_name.length > ${nameSchema.maxLength}) errors.push({ message: ${msg}, path: ${at} })`);
758
- }
759
- if (hasEnum(nameSchema)) {
760
- const allowed = JSON.stringify(nameSchema.enum);
761
- const label = nameSchema.enum.map((v) => JSON.stringify(v)).join(', ');
762
- const msg = JSON.stringify(`property name must be one of: ${label}`);
763
- checks.push(` if (!(${allowed} as unknown[]).includes(_name)) errors.push({ message: ${msg}, path: ${at} })`);
764
- }
765
- if (hasConst(nameSchema)) {
766
- const msg = JSON.stringify(`property name must be ${JSON.stringify(nameSchema.const)}`);
767
- checks.push(` if (_name !== ${JSON.stringify(nameSchema.const)}) errors.push({ message: ${msg}, path: ${at} })`);
768
- }
769
- }
885
+ const nameCtx = {
886
+ objVar: ctx.objVar,
887
+ pathPrefix: `${ctx.pathPrefix}/\${_name}`,
888
+ depth: ctx.depth + 1,
889
+ hoisted: ctx.hoisted,
890
+ };
891
+ const checks = generateValueChecks('', '_name', at, nameSchema, suffix, nameCtx, true);
770
892
  if (checks.length === 0)
771
893
  return [];
772
- return [` for (const _name of Object.keys(${ctx.objVar})) {`, ...checks, ` }`];
894
+ return [` for (const _name of Object.keys(${ctx.objVar})) {`, ...checks.map((line) => ` ${line}`), ` }`];
773
895
  };
774
896
  /**
775
897
  * Emits `dependentRequired` checks: when a trigger key is present, each of its
@@ -794,6 +916,129 @@ const generateDependentRequiredChecks = (schema, ctx) => {
794
916
  }
795
917
  return lines;
796
918
  };
919
+ /**
920
+ * Emits `dependentSchemas` checks (2020-12): when a trigger property is present,
921
+ * the *whole object* must also match the associated subschema. Mirrors the
922
+ * interpreter, which applies the subschema in place against the object. A `true`
923
+ * subschema permits everything (no-op); a `false` subschema makes the trigger's
924
+ * presence always invalid.
925
+ */
926
+ const generateDependentSchemasChecks = (schema, suffix, ctx) => {
927
+ if (!isSchemaObject(schema))
928
+ return [];
929
+ const dep = schema['dependentSchemas'];
930
+ if (typeof dep !== 'object' || dep === null || Array.isArray(dep))
931
+ return [];
932
+ const obj = ctx.objVar;
933
+ const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
934
+ const objPath = `\`${ctx.pathPrefix}\``;
935
+ const lines = [];
936
+ for (const [trigger, sub] of Object.entries(dep)) {
937
+ if (sub === true)
938
+ continue;
939
+ if (sub === false) {
940
+ const msg = JSON.stringify(`must NOT have property '${trigger}'`);
941
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
942
+ lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
943
+ lines.push(` }`);
944
+ continue;
945
+ }
946
+ if (!isSchemaObject(sub))
947
+ continue;
948
+ // The subschema applies to the object itself, so validate the current object
949
+ // variable against it and gate the whole block on the trigger's presence.
950
+ const checks = generateValueChecks('', obj, objPath, sub, suffix, ctx);
951
+ if (checks.length === 0)
952
+ continue;
953
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
954
+ lines.push(...checks.map((line) => ` ${line}`));
955
+ lines.push(` }`);
956
+ }
957
+ return lines;
958
+ };
959
+ /**
960
+ * Emits draft-07 `dependencies` — the dual-form predecessor of
961
+ * `dependentRequired` + `dependentSchemas`. When a trigger property is present,
962
+ * an array value requires each listed key, and a schema value is applied to the
963
+ * *whole object*. Mirrors the interpreter, which branches on the value's shape.
964
+ * A `false` subschema makes the trigger's mere presence invalid; a `true`
965
+ * subschema is a no-op.
966
+ */
967
+ const generateDependenciesChecks = (schema, suffix, ctx) => {
968
+ if (!isSchemaObject(schema))
969
+ return [];
970
+ const dep = schema['dependencies'];
971
+ if (typeof dep !== 'object' || dep === null || Array.isArray(dep))
972
+ return [];
973
+ const obj = ctx.objVar;
974
+ const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
975
+ const objPath = `\`${ctx.pathPrefix}\``;
976
+ const lines = [];
977
+ for (const [trigger, value] of Object.entries(dep)) {
978
+ // Array form: each listed key must be present when the trigger is.
979
+ if (Array.isArray(value)) {
980
+ for (const key of value) {
981
+ if (typeof key !== 'string')
982
+ continue;
983
+ const msg = JSON.stringify(`must have property '${key}' when '${trigger}' is present`);
984
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${JSON.stringify(key)} in ${obj})) {`);
985
+ lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
986
+ lines.push(` }`);
987
+ }
988
+ continue;
989
+ }
990
+ // Schema form: the subschema applies to the object itself.
991
+ if (value === true)
992
+ continue;
993
+ if (value === false) {
994
+ const msg = JSON.stringify(`must NOT have property '${trigger}'`);
995
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
996
+ lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
997
+ lines.push(` }`);
998
+ continue;
999
+ }
1000
+ if (!isSchemaObject(value))
1001
+ continue;
1002
+ const checks = generateValueChecks('', obj, objPath, value, suffix, ctx);
1003
+ if (checks.length === 0)
1004
+ continue;
1005
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj}) {`);
1006
+ lines.push(...checks.map((line) => ` ${line}`));
1007
+ lines.push(` }`);
1008
+ }
1009
+ return lines;
1010
+ };
1011
+ /**
1012
+ * Emits `minProperties` / `maxProperties` bounds on the object's key count,
1013
+ * mirroring the interpreter (which counts the object's own enumerable keys and
1014
+ * reports at the object node). Counting once into a depth-scoped local keeps the
1015
+ * two bounds from re-walking the keys.
1016
+ */
1017
+ const generateMinMaxPropertiesChecks = (schema, ctx) => {
1018
+ if (!isSchemaObject(schema))
1019
+ return [];
1020
+ const hasMin = hasMinProperties(schema);
1021
+ const hasMax = hasMaxProperties(schema);
1022
+ if (!hasMin && !hasMax)
1023
+ return [];
1024
+ const obj = ctx.objVar;
1025
+ const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
1026
+ const count = `_pc${ctx.depth}`;
1027
+ const lines = [` const ${count} = Object.keys(${obj}).length`];
1028
+ if (hasMin) {
1029
+ const msg = JSON.stringify(`must have at least ${schema.minProperties} properties`);
1030
+ lines.push(` if (${count} < ${schema.minProperties}) {`);
1031
+ lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
1032
+ lines.push(` }`);
1033
+ }
1034
+ if (hasMax) {
1035
+ const msg = JSON.stringify(`must have at most ${schema.maxProperties} properties`);
1036
+ lines.push(` if (${count} > ${schema.maxProperties}) {`);
1037
+ lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
1038
+ lines.push(` }`);
1039
+ }
1040
+ return lines;
1041
+ };
797
1042
  /**
798
1043
  * Builds the `&&` conditions that prove a single property is valid, or `null`
799
1044
  * when the property carries any keyword the slow path enforces beyond a bare
@@ -803,6 +1048,17 @@ const generateDependentRequiredChecks = (schema, ctx) => {
803
1048
  * input, never weakening a verdict. `objAcc` is the expression yielding the
804
1049
  * parent object (already narrowed to a record); `key` indexes into it.
805
1050
  */
1051
+ /**
1052
+ * Whether an object schema carries a combinator keyword (`allOf`, `anyOf`,
1053
+ * `oneOf`, `not`, `if`) that the error-collecting slow path enforces but neither
1054
+ * flat guard can mirror. When present, both guards must bail so their early
1055
+ * `return true` never accepts a document the combinator would reject.
1056
+ */
1057
+ const hasObjectLevelCombinator = (schema) => {
1058
+ if (!isSchemaObject(schema))
1059
+ return false;
1060
+ return hasAllOf(schema) || hasAnyOf(schema) || hasOneOf(schema) || 'not' in schema || 'if' in schema;
1061
+ };
806
1062
  const guardPropConditions = (key, propSchema, objAcc) => {
807
1063
  if (!isSchemaObject(propSchema))
808
1064
  return null;
@@ -898,10 +1154,17 @@ const arrayRejectedByRequiredProp = (keys, required, properties) => {
898
1154
  const guardObjectConditions = (schema, raw, objAcc) => {
899
1155
  if (!isObjectSchema(schema))
900
1156
  return null;
901
- if (hasDependentRequired(schema) || hasPropertyNames(schema))
1157
+ if (hasDependentRequired(schema) || hasPropertyNames(schema) || 'dependentSchemas' in schema)
1158
+ return null;
1159
+ if (hasMinProperties(schema) || hasMaxProperties(schema) || 'dependencies' in schema)
902
1160
  return null;
903
1161
  if (isSchemaObject(schema) && 'patternProperties' in schema)
904
1162
  return null;
1163
+ // Object-level combinators are enforced by the slow path but cannot be mirrored
1164
+ // by this flat guard, so bail — otherwise the guard's early `return true` would
1165
+ // accept documents the combinators reject.
1166
+ if (hasObjectLevelCombinator(schema))
1167
+ return null;
905
1168
  let strict = false;
906
1169
  if (hasAdditionalProperties(schema)) {
907
1170
  // Only `additionalProperties: false` is guardable (via the key-count trick
@@ -969,6 +1232,14 @@ const generateObjectValidator = (schema, typeName, suffix) => {
969
1232
  propertyLines.push(...generateStrictKeyChecks(schema, ctx));
970
1233
  // dependentRequired — when a trigger property is present, its dependencies must be too.
971
1234
  propertyLines.push(...generateDependentRequiredChecks(schema, ctx));
1235
+ // dependentSchemas — when a trigger property is present, the whole object must
1236
+ // also match the associated subschema.
1237
+ propertyLines.push(...generateDependentSchemasChecks(schema, suffix, ctx));
1238
+ // dependencies (draft-07) — the dual-form predecessor of dependentRequired +
1239
+ // dependentSchemas.
1240
+ propertyLines.push(...generateDependenciesChecks(schema, suffix, ctx));
1241
+ // minProperties / maxProperties — bound the object's key count.
1242
+ propertyLines.push(...generateMinMaxPropertiesChecks(schema, ctx));
972
1243
  // propertyNames — every key (always a string) must satisfy the subschema. This
973
1244
  // mirrors the interpreter, which runs the full subschema against each key.
974
1245
  if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
@@ -1040,32 +1311,6 @@ const generateObjectValidator = (schema, typeName, suffix) => {
1040
1311
  * e.g. "InfoObject" → "isInfoObject"
1041
1312
  */
1042
1313
  const guardName = (typeName) => `is${typeName}`;
1043
- /**
1044
- * Positive type check for a value — the negation of {@link wrongTypeCondition}.
1045
- * Used by the boolean type-guard, which proves validity with `&&` conditions
1046
- * rather than collecting errors. Object is a shape-only check (matching the
1047
- * validator, which never recurses into array items or untyped object values).
1048
- */
1049
- const rightTypeCondition = (accessor, type) => {
1050
- switch (type) {
1051
- case 'string':
1052
- return `typeof ${accessor} === 'string'`;
1053
- case 'number':
1054
- return `typeof ${accessor} === 'number'`;
1055
- case 'integer':
1056
- return `typeof ${accessor} === 'number' && Number.isInteger(${accessor})`;
1057
- case 'boolean':
1058
- return `typeof ${accessor} === 'boolean'`;
1059
- case 'array':
1060
- return `Array.isArray(${accessor})`;
1061
- case 'null':
1062
- return `${accessor} === null`;
1063
- case 'object':
1064
- return `typeof ${accessor} === 'object' && ${accessor} !== null && !Array.isArray(${accessor})`;
1065
- default:
1066
- return null;
1067
- }
1068
- };
1069
1314
  /**
1070
1315
  * Builds the membership test for an `enum`, matching the slow path's
1071
1316
  * `[...].includes(value)` verdict exactly. For the common all-primitive case it
@@ -1163,10 +1408,10 @@ const booleanLeafExpr = (schema, acc) => {
1163
1408
  }
1164
1409
  };
1165
1410
  /**
1166
- * Boolean expression for an array value. Mirrors the validator, which checks the
1167
- * array shape and — for typed items — only each item's *type* (objects are shape-
1168
- * checked, not recursed into); it never enforces `minItems`/`maxItems` or item
1169
- * constraints. Returns `null` for `$ref` items (those defer to the validator).
1411
+ * Boolean expression for an array value. Mirrors the validator: array shape,
1412
+ * `minItems`/`maxItems`/`uniqueItems`, and each item validated in full via
1413
+ * {@link booleanLeafExpr}. Returns `null` for `$ref` items, or when an item schema
1414
+ * can't be expressed flat, so the whole guard defers to the validator.
1170
1415
  *
1171
1416
  * Item iteration goes through `Array.from` rather than `Array.prototype.every`
1172
1417
  * because `every` *skips holes* in a sparse array (`[, 'x']`), whereas the
@@ -1183,7 +1428,11 @@ const booleanArrayExpr = (schema, acc) => {
1183
1428
  if (hasMaxItems(schema))
1184
1429
  parts.push(`${acc}.length <= ${schema.maxItems}`);
1185
1430
  if (hasUniqueItems(schema) && schema.uniqueItems === true) {
1186
- parts.push(`new Set((${acc} as unknown[]).map((_u) => JSON.stringify(_u))).size === ${acc}.length`);
1431
+ // Same scalar-vs-structural split as the validator, so the guard's verdict
1432
+ // matches the slow path's for object items in a reordered key order.
1433
+ parts.push(arrayItemsAreScalarOnly(schema)
1434
+ ? `new Set((${acc} as unknown[]).map((_u) => JSON.stringify(_u))).size === ${acc}.length`
1435
+ : `allUnique(${acc} as unknown[])`);
1187
1436
  }
1188
1437
  const base = parts.join(' && ');
1189
1438
  if (!hasItems(schema))
@@ -1193,12 +1442,14 @@ const booleanArrayExpr = (schema, acc) => {
1193
1442
  return base;
1194
1443
  if (hasRef(items))
1195
1444
  return null;
1196
- if (!hasType(items))
1197
- return base;
1198
- const itemCheck = rightTypeCondition('_it', items.type);
1199
- if (itemCheck === null)
1200
- return base;
1201
- return `${base} && Array.from(${acc} as unknown[]).every((_it) => ${itemCheck})`;
1445
+ // Validate each item in full, mirroring the validator's per-item checks so the
1446
+ // guard reaches the identical verdict. `booleanLeafExpr` returns `null` for item
1447
+ // schemas it can't express flat — bail so the validator decides, keeping the
1448
+ // guard from ever accepting what the slow path would reject.
1449
+ const itemExpr = booleanLeafExpr(items, '_it');
1450
+ if (itemExpr === null)
1451
+ return null;
1452
+ return `${base} && Array.from(${acc} as unknown[]).every((_it) => (${itemExpr}))`;
1202
1453
  };
1203
1454
  /**
1204
1455
  * Builds the `&&` conditions proving an object value is valid (same verdict as
@@ -1210,10 +1461,16 @@ const booleanObjectParts = (schema, raw, objAcc) => {
1210
1461
  if (!isObjectSchema(schema))
1211
1462
  return null;
1212
1463
  // These need per-key loops or cross-references the flat form can't express.
1213
- if (hasDependentRequired(schema) || hasPropertyNames(schema))
1464
+ if (hasDependentRequired(schema) || hasPropertyNames(schema) || 'dependentSchemas' in schema)
1465
+ return null;
1466
+ if (hasMinProperties(schema) || hasMaxProperties(schema) || 'dependencies' in schema)
1214
1467
  return null;
1215
1468
  if (isSchemaObject(schema) && 'patternProperties' in schema)
1216
1469
  return null;
1470
+ // Object-level combinators change the verdict but can't be expressed flat, so
1471
+ // defer to the validator rather than emit a guard that ignores them.
1472
+ if (hasObjectLevelCombinator(schema))
1473
+ return null;
1217
1474
  let strict = false;
1218
1475
  if (hasAdditionalProperties(schema)) {
1219
1476
  // Only `additionalProperties: false` is expressible; a schema needs per-key
@@ -1272,8 +1529,12 @@ const booleanObjectParts = (schema, raw, objAcc) => {
1272
1529
  export const generateBooleanGuard = (schema, typeName, _suffix = '') => {
1273
1530
  const name = guardName(typeName);
1274
1531
  const fallback = `export const ${name} = (input: unknown): input is ${typeName} => ${validatorName(typeName)}(input) === true`;
1275
- if (isObjectSchema(schema)) {
1276
- const parts = booleanObjectParts(schema, 'input', 'obj');
1532
+ // Fold `nullable: true` into `anyOf` so the guard's verdict matches the
1533
+ // validator's (which applies the same rewrite). Without this a nullable node's
1534
+ // guard would reject `null` while the validator accepts it.
1535
+ const rewritten = rewriteNullable(schema);
1536
+ if (isObjectSchema(rewritten)) {
1537
+ const parts = booleanObjectParts(rewritten, 'input', 'obj');
1277
1538
  if (parts === null)
1278
1539
  return fallback;
1279
1540
  return [
@@ -1286,7 +1547,7 @@ export const generateBooleanGuard = (schema, typeName, _suffix = '') => {
1286
1547
  ].join('\n');
1287
1548
  }
1288
1549
  // Non-object roots (scalar, enum, array) can often be expressed inline too.
1289
- const expr = booleanLeafExpr(schema, 'input');
1550
+ const expr = booleanLeafExpr(rewritten, 'input');
1290
1551
  if (expr === null)
1291
1552
  return fallback;
1292
1553
  return `export const ${name} = (input: unknown): input is ${typeName} => ${expr}`;
@@ -1364,10 +1625,44 @@ const generateScalarValidator = (schema, typeName, suffix) => {
1364
1625
  // (exactly one) and inline branches included, not just `$ref` branches.
1365
1626
  if (hasAllOf(schema) || hasAnyOf(schema) || hasOneOf(schema) || 'not' in schema || 'if' in schema) {
1366
1627
  const ctx = createRootContext();
1367
- const checks = generateCombinatorChecks('', 'input', '`${_path}`', schema, suffix, ctx);
1628
+ const checks = [];
1629
+ // The root path expression the shared emitters use, as a template literal body.
1630
+ const rootPath = '`${_path}`';
1631
+ // A `type` (and its sibling value constraints) alongside a combinator still
1632
+ // applies — the value must satisfy BOTH. Emit the type/constraint checks first,
1633
+ // then the combinator checks, so a schema like `{ type: 'string', not: {…} }`
1634
+ // or `{ type: 'number', minimum: 10, allOf: [{ maximum: 100 }] }` no longer
1635
+ // drops the `type` check and its siblings.
1636
+ const rootTypeArray = getTypeArray(schema);
1637
+ if (rootTypeArray) {
1638
+ const allWrong = rootTypeArray
1639
+ .map((t) => wrongTypeCondition('input', t))
1640
+ .filter((c) => c !== '')
1641
+ .map((c) => `(${c})`)
1642
+ .join(' && ');
1643
+ if (allWrong) {
1644
+ const label = rootTypeArray.map((t) => typeofString(t)).join(' or ');
1645
+ checks.push(` if (${allWrong}) {`);
1646
+ checks.push(` errors.push({ message: ${JSON.stringify(`must be ${label}`)}, path: ${rootPath} })`);
1647
+ checks.push(` }`);
1648
+ }
1649
+ checks.push(...generateConstraintChecks('', 'input', rootPath, schema, suffix, ctx));
1650
+ }
1651
+ else if (hasType(schema)) {
1652
+ const t = schema.type;
1653
+ const wrongType = wrongTypeCondition('input', t);
1654
+ if (wrongType) {
1655
+ checks.push(` if (${wrongType}) {`);
1656
+ checks.push(` errors.push({ message: 'must be ${typeofString(t)}', path: ${rootPath} })`);
1657
+ checks.push(` }`);
1658
+ }
1659
+ checks.push(...generateConstraintChecks('', 'input', rootPath, schema, suffix, ctx));
1660
+ }
1661
+ checks.push(...generateCombinatorChecks('', 'input', rootPath, schema, suffix, ctx));
1368
1662
  const body = checks.join('\n').replaceAll('errors.push(', '(errors ??= []).push(');
1663
+ const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
1369
1664
  return [
1370
- `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
1665
+ `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
1371
1666
  ` let errors: ValidationError[] | undefined`,
1372
1667
  body,
1373
1668
  ` return errors !== undefined ? { valid: false, errors } : true`,
@@ -1487,6 +1782,76 @@ const assertNoUnsupportedKeywords = (schema, typeName) => {
1487
1782
  };
1488
1783
  visit(schema);
1489
1784
  };
1785
+ /** Keywords whose value is a single subschema (or a boolean schema). */
1786
+ const SINGLE_SUBSCHEMA_KEYS = new Set([
1787
+ 'additionalProperties',
1788
+ 'additionalItems',
1789
+ 'contains',
1790
+ 'propertyNames',
1791
+ 'not',
1792
+ 'if',
1793
+ 'then',
1794
+ 'else',
1795
+ 'unevaluatedProperties',
1796
+ 'unevaluatedItems',
1797
+ ]);
1798
+ /** Keywords whose value is an array of subschemas. */
1799
+ const SUBSCHEMA_LIST_KEYS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
1800
+ /** Keywords whose value is a map of names to subschemas. */
1801
+ const SUBSCHEMA_MAP_KEYS = new Set(['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions']);
1802
+ /**
1803
+ * Rewrites OpenAPI 3.0 `nullable: true` into a form the generator already
1804
+ * enforces. A node `{ nullable: true, ...rest }` accepts a value iff the value is
1805
+ * `null` OR it matches `rest` — exactly `{ anyOf: [{ type: 'null' }, rest] }`.
1806
+ * Emitting that lets the existing `anyOf` machinery (and its guard bail-outs)
1807
+ * mirror the interpreter, which short-circuits every keyword when a `nullable`
1808
+ * node sees `null`.
1809
+ *
1810
+ * The walk descends only into genuine subschema positions (never into `enum` /
1811
+ * `const` / `default` data), so a data value that merely looks like a schema is
1812
+ * never rewritten. Returns a fresh tree; the caller's schema is untouched.
1813
+ */
1814
+ const rewriteNullable = (node) => {
1815
+ if (typeof node !== 'object' || node === null || Array.isArray(node))
1816
+ return node;
1817
+ const src = node;
1818
+ const out = {};
1819
+ for (const [key, value] of Object.entries(src)) {
1820
+ if (key === 'nullable')
1821
+ continue; // folded into the `anyOf` wrapper below
1822
+ if (SINGLE_SUBSCHEMA_KEYS.has(key)) {
1823
+ out[key] = rewriteNullable(value);
1824
+ }
1825
+ else if (key === 'items') {
1826
+ // `items` is either a single subschema (2020-12) or a tuple array (draft).
1827
+ out[key] = Array.isArray(value) ? value.map(rewriteNullable) : rewriteNullable(value);
1828
+ }
1829
+ else if (SUBSCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
1830
+ out[key] = value.map(rewriteNullable);
1831
+ }
1832
+ else if (SUBSCHEMA_MAP_KEYS.has(key) && typeof value === 'object' && value !== null) {
1833
+ const mapped = {};
1834
+ for (const [name, sub] of Object.entries(value))
1835
+ mapped[name] = rewriteNullable(sub);
1836
+ out[key] = mapped;
1837
+ }
1838
+ else if (key === 'dependencies' && typeof value === 'object' && value !== null) {
1839
+ // Dual-form: an array value lists required keys (data), a schema value is a
1840
+ // subschema.
1841
+ const mapped = {};
1842
+ for (const [name, sub] of Object.entries(value)) {
1843
+ mapped[name] = Array.isArray(sub) ? sub : rewriteNullable(sub);
1844
+ }
1845
+ out[key] = mapped;
1846
+ }
1847
+ else {
1848
+ out[key] = value;
1849
+ }
1850
+ }
1851
+ if (src['nullable'] === true)
1852
+ return { anyOf: [{ type: 'null' }, out] };
1853
+ return out;
1854
+ };
1490
1855
  /**
1491
1856
  * Generates a TypeScript validator function from a JSON Schema.
1492
1857
  *
@@ -1511,8 +1876,11 @@ const assertNoUnsupportedKeywords = (schema, typeName) => {
1511
1876
  */
1512
1877
  export const generateValidatorFunction = (schema, typeName, suffix = '') => {
1513
1878
  assertNoUnsupportedKeywords(schema, typeName);
1514
- if (isObjectSchema(schema)) {
1515
- return generateObjectValidator(schema, typeName, suffix);
1879
+ // Fold OpenAPI `nullable: true` into the `anyOf` form the generator already
1880
+ // enforces, so the emitted checks match the interpreter's null short-circuit.
1881
+ const rewritten = rewriteNullable(schema);
1882
+ if (isObjectSchema(rewritten)) {
1883
+ return generateObjectValidator(rewritten, typeName, suffix);
1516
1884
  }
1517
- return generateScalarValidator(schema, typeName, suffix);
1885
+ return generateScalarValidator(rewritten, typeName, suffix);
1518
1886
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.11.5",
3
+ "version": "0.11.7",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -46,9 +46,10 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "json-schema-typed": "^8.0.1",
49
- "@amritk/helpers": "0.12.0"
49
+ "@amritk/helpers": "0.13.1"
50
50
  },
51
51
  "devDependencies": {
52
+ "@amritk/runtime-validators": "0.7.1",
52
53
  "@ryoppippi/unplugin-typia": "^2.6.5",
53
54
  "@scalar/openapi-parser": "^0.26.1",
54
55
  "@sinclair/typebox": "^0.34.49",