@amritk/generate-validators 0.6.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
@@ -92,6 +92,43 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
92
92
 
93
93
  ---
94
94
 
95
+ ## Benchmarks
96
+
97
+ Generated validators are straight-line, monomorphic TypeScript with no generic
98
+ dispatch. On the happy path they run a single allocation-free boolean guard — a
99
+ pure `&&` chain of `typeof` checks (plus an `Object.keys().length` count when an
100
+ object is closed with `additionalProperties: false`) — and only fall back to the
101
+ error-collecting body when something is actually wrong. That makes a valid-input
102
+ check as cheap as TypeBox's compiled checker while still emitting full
103
+ JSON-Pointer errors for invalid input, and emitting the validator stays far
104
+ cheaper than compiling a schema at startup. Measured on Bun 1.3 (Linux x64),
105
+ validating valid input at steady state:
106
+
107
+ | schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
108
+ |:--|--:|--:|--:|--:|
109
+ | small (4 fields) | **~37M** ops/s | ~10M ops/s | ~4.9M ops/s | ~2.0M ops/s |
110
+ | order (nested + array) | **~11M** ops/s | ~3.7M ops/s | ~2.0M ops/s | ~0.5M ops/s |
111
+ | assert-loose | **~67M** ops/s | ~40M ops/s | ~57M ops/s | ~3.2M ops/s |
112
+ | assert-strict | **~47M** ops/s | ~19M ops/s | ~36M ops/s | ~1.3M ops/s |
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
122
+ libraries agree on every verdict; parity is asserted before timing (TypeBox is
123
+ given uuid/email format checkers so every library does the same work).
124
+ Micro-benchmark figures vary by machine and runtime — reproduce with:
125
+
126
+ ```bash
127
+ bun run bench
128
+ ```
129
+
130
+ ---
131
+
95
132
  ## Related packages
96
133
 
97
134
  - [`@amritk/generate-parsers`](../generate-parsers) — type definitions plus parsers that coerce input
@@ -29,4 +29,3 @@ export type GeneratedFile = {
29
29
  * ```
30
30
  */
31
31
  export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string) => Promise<GeneratedFile[]>;
32
- //# sourceMappingURL=build-schema.d.ts.map
@@ -16,6 +16,35 @@ export type ValidationError = {
16
16
  * and a list of errors when it is not.
17
17
  */
18
18
  export type ValidationResult = true | { valid: false; errors: ValidationError[] }
19
+
20
+ /**
21
+ * Structural deep equality used by generated \`const\` checks. Objects compare by
22
+ * their key sets rather than serialization, so \`{ a: 1, b: 2 }\` and
23
+ * \`{ b: 2, a: 1 }\` are equal — unlike \`JSON.stringify\`, which is key-order
24
+ * sensitive and would reject a reordered-but-equal value.
25
+ */
26
+ export const valuesEqual = (a: unknown, b: unknown): boolean => {
27
+ if (a === b) return true
28
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
29
+ const aArray = Array.isArray(a)
30
+ const bArray = Array.isArray(b)
31
+ if (aArray !== bArray) return false
32
+ if (aArray) {
33
+ const aa = a as unknown[]
34
+ const bb = b as unknown[]
35
+ if (aa.length !== bb.length) return false
36
+ for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i])) return false
37
+ return true
38
+ }
39
+ const ao = a as Record<string, unknown>
40
+ const bo = b as Record<string, unknown>
41
+ const keys = Object.keys(ao)
42
+ if (keys.length !== Object.keys(bo).length) return false
43
+ for (const key of keys) {
44
+ if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key])) return false
45
+ }
46
+ return true
47
+ }
19
48
  `;
20
49
  /**
21
50
  * Builds all TypeScript validator files from a JSON Schema by traversing all
@@ -32,4 +32,3 @@ type CollectValidatorImportsOptions = {
32
32
  */
33
33
  export declare const collectValidatorImports: (schema: JSONSchema, options?: CollectValidatorImportsOptions) => string[];
34
34
  export {};
35
- //# sourceMappingURL=collect-validator-imports.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { refToFilename } from '@amritk/helpers/ref-to-filename';
2
2
  import { refToName } from '@amritk/helpers/ref-to-name';
3
3
  import { resolveRef } from '@amritk/helpers/resolve-ref';
4
- import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
4
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasProperties, hasRef, } from '@amritk/helpers/schema-guards';
5
5
  /**
6
6
  * Generates an import statement for a single $ref, importing both the type
7
7
  * and the validator function from the ref's generated file.
@@ -43,6 +43,10 @@ const collectDirectRefs = (schema) => {
43
43
  if (hasAdditionalProperties(prop) && hasRef(prop.additionalProperties)) {
44
44
  refs.push(prop.additionalProperties.$ref);
45
45
  }
46
+ // Inline nested objects are validated recursively by the generator, so any
47
+ // $refs anywhere inside them must become imports as well.
48
+ if (hasProperties(prop))
49
+ refs.push(...collectDirectRefs(prop));
46
50
  }
47
51
  if (hasItems(schema) && hasRef(schema.items)) {
48
52
  refs.push(schema.items.$ref);
@@ -42,4 +42,3 @@ type GenerateValidatorFileOptions = {
42
42
  */
43
43
  export declare const generateValidatorFile: (schema: JSONSchema, typeName: string, options?: GenerateValidatorFileOptions) => string;
44
44
  export {};
45
- //# sourceMappingURL=generate-files.d.ts.map
@@ -33,6 +33,12 @@ export const generateValidatorFile = (schema, typeName, options) => {
33
33
  const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix });
34
34
  const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix);
35
35
  let result = `import type { ValidationResult, ValidationError } from './validation-result'\n`;
36
+ // `const` checks on object/array values call the runtime `valuesEqual` helper.
37
+ // Only import it when the generated body actually uses it, so files without a
38
+ // structural `const` do not carry an unused import.
39
+ if (validatorFunction.includes('valuesEqual(')) {
40
+ result += `import { valuesEqual } from './validation-result'\n`;
41
+ }
36
42
  for (const imp of refImports) {
37
43
  result += imp + '\n';
38
44
  }
@@ -22,4 +22,3 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
22
22
  * ```
23
23
  */
24
24
  export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string) => string;
25
- //# sourceMappingURL=generate-validator-function.d.ts.map
@@ -1,7 +1,8 @@
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 { hasAdditionalProperties, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
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"
@@ -17,15 +18,16 @@ const typeofString = (type) => {
17
18
  };
18
19
  /**
19
20
  * Generates the inline condition that is TRUE when `accessor` does NOT equal the
20
- * `const` value. Primitives compare with `!==`; objects/arrays compare by their
21
- * canonical JSON serialization (sufficient for the literal, fixed shapes `const`
22
- * is used for).
21
+ * `const` value. Primitives compare with `!==`; objects/arrays compare with the
22
+ * runtime `valuesEqual` helper so a reordered-but-equal value still matches (the
23
+ * interpreter uses order-independent deep equality, and `JSON.stringify` would
24
+ * disagree because it is key-order sensitive).
23
25
  */
24
26
  const constMismatchCondition = (accessor, value) => {
25
27
  if (value === null || typeof value !== 'object') {
26
28
  return `${accessor} !== ${JSON.stringify(value)}`;
27
29
  }
28
- return `JSON.stringify(${accessor}) !== ${JSON.stringify(JSON.stringify(value))}`;
30
+ return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
29
31
  };
30
32
  /**
31
33
  * Generates the inline condition that is TRUE when a value is the wrong type.
@@ -47,23 +49,79 @@ const wrongTypeCondition = (accessor, type) => {
47
49
  return '';
48
50
  }
49
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
+ };
65
+ /**
66
+ * Generates the unknown-key sweep for `additionalProperties: false`, mirroring
67
+ * the interpreter's behaviour (same error message, one error per extra key).
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.
76
+ */
77
+ const generateStrictKeyChecks = (schema, ctx) => {
78
+ if (!isSchemaObject(schema))
79
+ return [];
80
+ if (!hasAdditionalProperties(schema) || schema.additionalProperties !== false)
81
+ return [];
82
+ const known = Object.keys(hasProperties(schema) ? schema.properties : {});
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
+ }
95
+ return [
96
+ ` for (const _key${d} in ${ctx.objVar}) {`,
97
+ ` if (${check.isUnknown(`_key${d}`)}${patternGuard}) {`,
98
+ ` errors.push({ message: 'must NOT have additional properties', path: \`${ctx.pathPrefix}/\${_key${d}}\` })`,
99
+ ` }`,
100
+ ` }`,
101
+ ];
102
+ };
50
103
  /**
51
104
  * Generates validation lines for a single property in an object schema.
52
- * Handles $ref delegation, enum checks, type checks, and string/number constraints.
105
+ * Handles $ref delegation, enum checks, type checks, string/number constraints,
106
+ * and recursion into inline nested objects.
53
107
  */
54
- const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
108
+ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
55
109
  if (!isSchemaObject(propSchema))
56
110
  return [];
57
- const raw = `obj[${JSON.stringify(key)}]`;
58
- const path = `\`\${_path}/${key}\``;
111
+ const raw = `${ctx.objVar}[${JSON.stringify(key)}]`;
112
+ const path = `\`${ctx.pathPrefix}/${key}\``;
113
+ // Missing-property errors report at the parent object's path. At the root
114
+ // that is the `_path` parameter itself; inside nested objects it is the
115
+ // parent's accumulated static path.
116
+ const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
59
117
  const lines = [];
60
118
  // $ref — delegate to the imported validator
61
119
  if (hasRef(propSchema)) {
62
120
  const ref = propSchema.$ref;
63
121
  const vName = validatorName(refToName(ref, suffix));
64
122
  if (isRequired) {
65
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
66
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
123
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
124
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
67
125
  lines.push(` } else {`);
68
126
  lines.push(` const _r = ${vName}(${raw}, ${path})`);
69
127
  lines.push(` if (_r !== true) errors.push(..._r.errors)`);
@@ -81,8 +139,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
81
139
  const instanceOf = getMjstInstanceOf(propSchema);
82
140
  if (instanceOf) {
83
141
  if (isRequired) {
84
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
85
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
142
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
143
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
86
144
  lines.push(` } else if (!(${raw} instanceof ${instanceOf})) {`);
87
145
  lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
88
146
  lines.push(` }`);
@@ -98,8 +156,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
98
156
  const primitive = getMjstPrimitive(propSchema);
99
157
  if (primitive) {
100
158
  if (isRequired) {
101
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
102
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
159
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
160
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
103
161
  lines.push(` } else if (typeof ${raw} !== "${primitive}") {`);
104
162
  lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
105
163
  lines.push(` }`);
@@ -116,8 +174,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
116
174
  const mismatch = constMismatchCondition(raw, propSchema.const);
117
175
  const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
118
176
  if (isRequired) {
119
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
120
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
177
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
178
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
121
179
  lines.push(` } else if (${mismatch}) {`);
122
180
  lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
123
181
  lines.push(` }`);
@@ -134,8 +192,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
134
192
  const allowed = JSON.stringify(propSchema.enum);
135
193
  const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
136
194
  if (isRequired) {
137
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
138
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
195
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
196
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
139
197
  lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`);
140
198
  lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
141
199
  lines.push(` }`);
@@ -153,8 +211,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
153
211
  const wrongType = wrongTypeCondition(raw, t);
154
212
  const typLabel = typeofString(t);
155
213
  if (isRequired) {
156
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
157
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
214
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
215
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
158
216
  if (wrongType) {
159
217
  lines.push(` } else if (${wrongType}) {`);
160
218
  lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
@@ -189,13 +247,20 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
189
247
  // Number constraints
190
248
  if (t === 'number' || t === 'integer') {
191
249
  if (hasMinimum(propSchema)) {
192
- lines.push(` if (typeof ${raw} === 'number' && ${raw} < ${propSchema.minimum}) {`);
193
- lines.push(` errors.push({ message: 'must be >= ${propSchema.minimum}', path: ${path} })`);
250
+ // Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
251
+ const strict = hasStrictExclusiveMinimum(propSchema);
252
+ const op = strict ? '<=' : '<';
253
+ const rel = strict ? '>' : '>=';
254
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
255
+ lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
194
256
  lines.push(` }`);
195
257
  }
196
258
  if (hasMaximum(propSchema)) {
197
- lines.push(` if (typeof ${raw} === 'number' && ${raw} > ${propSchema.maximum}) {`);
198
- lines.push(` errors.push({ message: 'must be <= ${propSchema.maximum}', path: ${path} })`);
259
+ const strict = hasStrictExclusiveMaximum(propSchema);
260
+ const op = strict ? '>=' : '>';
261
+ const rel = strict ? '<' : '<=';
262
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
263
+ lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
199
264
  lines.push(` }`);
200
265
  }
201
266
  if (hasExclusiveMinimum(propSchema)) {
@@ -221,7 +286,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
221
286
  const vName = validatorName(refToName(itemSchema.$ref, suffix));
222
287
  lines.push(` if (Array.isArray(${raw})) {`);
223
288
  lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
224
- lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/${key}/\${_i}\`)`);
289
+ lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
225
290
  lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
226
291
  lines.push(` }`);
227
292
  lines.push(` }`);
@@ -234,15 +299,207 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
234
299
  lines.push(` if (Array.isArray(${raw})) {`);
235
300
  lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
236
301
  lines.push(` const _item = ${raw}[_i]`);
237
- lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/${key}/\${_i}\` })`);
302
+ lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
238
303
  lines.push(` }`);
239
304
  lines.push(` }`);
240
305
  }
241
306
  }
242
307
  }
308
+ // Inline nested object — recurse so the nested fields are actually
309
+ // validated. Without this only the "must be object" shape check above
310
+ // runs and everything inside the nested object silently passes.
311
+ if (t === 'object') {
312
+ lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
313
+ }
243
314
  }
244
315
  return lines;
245
316
  };
317
+ /**
318
+ * Generates the recursive checks for an inline nested object property, i.e. an
319
+ * object schema written directly under `properties` rather than referenced via
320
+ * `$ref` (those delegate to the referenced validator instead). The value is
321
+ * narrowed into its own block-scoped variable and each nested property runs
322
+ * through the same per-property generator, so nesting works to any depth.
323
+ */
324
+ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
325
+ if (!isSchemaObject(propSchema))
326
+ return [];
327
+ const child = {
328
+ objVar: `_obj${ctx.depth + 1}`,
329
+ pathPrefix: `${ctx.pathPrefix}/${key}`,
330
+ depth: ctx.depth + 1,
331
+ hoisted: ctx.hoisted,
332
+ };
333
+ const required = new Set(hasRequired(propSchema) ? propSchema.required : []);
334
+ const properties = hasProperties(propSchema) ? propSchema.properties : {};
335
+ const innerLines = [];
336
+ for (const [childKey, childSchema] of Object.entries(properties)) {
337
+ innerLines.push(...generatePropertyChecks(childKey, childSchema, required.has(childKey), suffix, child));
338
+ }
339
+ innerLines.push(...generateStrictKeyChecks(propSchema, child));
340
+ if (innerLines.length === 0)
341
+ return [];
342
+ // The shape check for the property itself already ran (or the property is
343
+ // optional), so re-guard here instead of assuming the value is an object.
344
+ return [
345
+ ` if (typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})) {`,
346
+ ` const ${child.objVar} = ${raw} as Record<string, unknown>`,
347
+ ...innerLines.map((line) => ` ${line}`),
348
+ ` }`,
349
+ ];
350
+ };
351
+ /**
352
+ * Generates the `propertyNames` loop: every object key is a string, so we apply
353
+ * the string-relevant constraints of the subschema (or delegate to a `$ref`'s
354
+ * validator). This keeps the generator in step with the interpreter, which runs
355
+ * the whole subschema against each key — not just the `pattern` form.
356
+ */
357
+ const generatePropertyNameChecks = (nameSchema, suffix) => {
358
+ if (!isSchemaObject(nameSchema))
359
+ return [];
360
+ const at = '`${_path}/${_name}`';
361
+ const checks = [];
362
+ if (hasRef(nameSchema)) {
363
+ const vName = validatorName(refToName(nameSchema.$ref, suffix));
364
+ checks.push(` const _nr = ${vName}(_name, ${at})`);
365
+ checks.push(` if (_nr !== true) errors.push(..._nr.errors)`);
366
+ }
367
+ else {
368
+ if (hasPattern(nameSchema)) {
369
+ const re = escapeRegexPattern(nameSchema.pattern);
370
+ const msg = JSON.stringify(`property name must match pattern ${nameSchema.pattern}`);
371
+ checks.push(` if (!/${re}/.test(_name)) errors.push({ message: ${msg}, path: ${at} })`);
372
+ }
373
+ if (hasMinLength(nameSchema)) {
374
+ const msg = JSON.stringify(`property name must have at least ${nameSchema.minLength} characters`);
375
+ checks.push(` if (_name.length < ${nameSchema.minLength}) errors.push({ message: ${msg}, path: ${at} })`);
376
+ }
377
+ if (hasMaxLength(nameSchema)) {
378
+ const msg = JSON.stringify(`property name must have at most ${nameSchema.maxLength} characters`);
379
+ checks.push(` if (_name.length > ${nameSchema.maxLength}) errors.push({ message: ${msg}, path: ${at} })`);
380
+ }
381
+ if (hasEnum(nameSchema)) {
382
+ const allowed = JSON.stringify(nameSchema.enum);
383
+ const label = nameSchema.enum.map((v) => JSON.stringify(v)).join(', ');
384
+ const msg = JSON.stringify(`property name must be one of: ${label}`);
385
+ checks.push(` if (!(${allowed} as unknown[]).includes(_name)) errors.push({ message: ${msg}, path: ${at} })`);
386
+ }
387
+ if (hasConst(nameSchema)) {
388
+ const msg = JSON.stringify(`property name must be ${JSON.stringify(nameSchema.const)}`);
389
+ checks.push(` if (_name !== ${JSON.stringify(nameSchema.const)}) errors.push({ message: ${msg}, path: ${at} })`);
390
+ }
391
+ }
392
+ if (checks.length === 0)
393
+ return [];
394
+ return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
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
+ };
246
503
  /**
247
504
  * Generates a validator function body for an object schema, checking each
248
505
  * property's presence and type and collecting all errors.
@@ -251,9 +508,10 @@ const generateObjectValidator = (schema, typeName, suffix) => {
251
508
  const vName = validatorName(typeName);
252
509
  const required = new Set(hasRequired(schema) ? schema.required : []);
253
510
  const properties = hasProperties(schema) ? schema.properties : {};
511
+ const ctx = createRootContext();
254
512
  const propertyLines = [];
255
513
  for (const [key, propSchema] of Object.entries(properties)) {
256
- const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix);
514
+ const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix, ctx);
257
515
  if (checks.length > 0) {
258
516
  propertyLines.push(...checks);
259
517
  }
@@ -269,6 +527,8 @@ const generateObjectValidator = (schema, typeName, suffix) => {
269
527
  propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
270
528
  propertyLines.push(` }`);
271
529
  }
530
+ // additionalProperties: false rejects every key not declared in properties
531
+ propertyLines.push(...generateStrictKeyChecks(schema, ctx));
272
532
  // dependentRequired — when a trigger property is present, its dependencies must be too.
273
533
  if (hasDependentRequired(schema)) {
274
534
  for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
@@ -282,26 +542,35 @@ const generateObjectValidator = (schema, typeName, suffix) => {
282
542
  }
283
543
  }
284
544
  }
285
- // propertyNames with a pattern — every key must match. (Only the pattern form
286
- // is emitted; a $ref/complex propertyNames schema is left to runtime validation.)
287
- if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames) && hasPattern(schema.propertyNames)) {
288
- const re = escapeRegexPattern(schema.propertyNames.pattern);
289
- const msg = JSON.stringify(`property name must match pattern ${schema.propertyNames.pattern}`);
290
- propertyLines.push(` for (const _name of Object.keys(obj)) {`);
291
- propertyLines.push(` if (!/${re}/.test(_name)) {`);
292
- propertyLines.push(` errors.push({ message: ${msg}, path: \`\${_path}/\${_name}\` })`);
293
- propertyLines.push(` }`);
294
- propertyLines.push(` }`);
545
+ // propertyNames — every key (always a string) must satisfy the subschema. This
546
+ // mirrors the interpreter, which runs the full subschema against each key.
547
+ if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
548
+ propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
295
549
  }
296
550
  const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '';
551
+ // Hoisted statements (e.g. known-keys Sets) come first so every call of the
552
+ // validator reuses them instead of rebuilding them.
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
+ : [];
297
565
  return [
298
- `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
566
+ `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
567
+ ` const obj = input as Record<string, unknown>`,
568
+ ...guardBlock,
299
569
  ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
300
570
  ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
301
571
  ` }`,
302
572
  ``,
303
573
  ` const errors: ValidationError[] = []`,
304
- ` const obj = input as Record<string, unknown>`,
305
574
  body,
306
575
  ` return errors.length > 0 ? { valid: false, errors } : true`,
307
576
  `}`,
package/dist/index.d.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  export type { GeneratedFile } from './generators/build-schema.js';
2
2
  export { buildValidatorSchema } from './generators/build-schema.js';
3
- //# sourceMappingURL=index.d.ts.map