@amritk/generate-validators 0.6.0 → 0.7.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,30 @@ 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, so they validate as fast as an Ajv-compiled function once emitted —
99
+ and emitting them is far cheaper than compiling a schema at startup. Measured
100
+ on Bun 1.3 (Linux x64), validating valid input at steady state:
101
+
102
+ | schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
103
+ |:--|--:|--:|--:|--:|
104
+ | small (4 fields) | **~9.5M** ops/s | ~9.3M ops/s | ~4.8M ops/s | ~1.7M ops/s |
105
+ | order (nested + array) | **~3.7M** ops/s | ~3.5M ops/s | ~2.0M ops/s | ~0.4M ops/s |
106
+
107
+ Preparing a validator costs ~0.15–0.20 ms for mjst codegen and ~0.12–0.21 ms for
108
+ a TypeBox `TypeCompiler` compile, versus ~13–14 ms for an Ajv compile. All four
109
+ libraries agree on every verdict; parity is asserted before timing (TypeBox is
110
+ given uuid/email format checkers so every library does the same work).
111
+ Micro-benchmark figures vary by machine and runtime — reproduce with:
112
+
113
+ ```bash
114
+ bun run bench
115
+ ```
116
+
117
+ ---
118
+
95
119
  ## Related packages
96
120
 
97
121
  - [`@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,7 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
3
  import { refToName } from '@amritk/helpers/ref-to-name';
4
- import { 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
5
  /**
6
6
  * Derives the validator function name from a type name.
7
7
  * e.g. "InfoObject" → "validateInfoObject"
@@ -17,15 +17,16 @@ const typeofString = (type) => {
17
17
  };
18
18
  /**
19
19
  * 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).
20
+ * `const` value. Primitives compare with `!==`; objects/arrays compare with the
21
+ * runtime `valuesEqual` helper so a reordered-but-equal value still matches (the
22
+ * interpreter uses order-independent deep equality, and `JSON.stringify` would
23
+ * disagree because it is key-order sensitive).
23
24
  */
24
25
  const constMismatchCondition = (accessor, value) => {
25
26
  if (value === null || typeof value !== 'object') {
26
27
  return `${accessor} !== ${JSON.stringify(value)}`;
27
28
  }
28
- return `JSON.stringify(${accessor}) !== ${JSON.stringify(JSON.stringify(value))}`;
29
+ return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
29
30
  };
30
31
  /**
31
32
  * Generates the inline condition that is TRUE when a value is the wrong type.
@@ -47,23 +48,57 @@ const wrongTypeCondition = (accessor, type) => {
47
48
  return '';
48
49
  }
49
50
  };
51
+ const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
52
+ /**
53
+ * Generates the unknown-key sweep for `additionalProperties: false`, mirroring
54
+ * the interpreter's behaviour (same error message, one error per extra key).
55
+ * The known-keys Set is hoisted to module scope and the sweep uses `for...in`
56
+ * — the same allocation-free shape Ajv compiles to — so the hot path costs one
57
+ * Set lookup per key. Schemas that combine it with `patternProperties` are
58
+ * skipped: the generator does not evaluate key patterns yet, so rejecting
59
+ * every undeclared key would wrongly fail keys the patterns allow.
60
+ */
61
+ const generateStrictKeyChecks = (schema, ctx) => {
62
+ if (!isSchemaObject(schema))
63
+ return [];
64
+ if (!hasAdditionalProperties(schema) || schema.additionalProperties !== false)
65
+ return [];
66
+ if ('patternProperties' in schema)
67
+ return [];
68
+ const known = Object.keys(hasProperties(schema) ? schema.properties : {});
69
+ const setName = `_knownKeys${ctx.hoisted.length}`;
70
+ ctx.hoisted.push(`const ${setName} = new Set(${JSON.stringify(known)})`);
71
+ const d = ctx.depth;
72
+ return [
73
+ ` for (const _key${d} in ${ctx.objVar}) {`,
74
+ ` if (!${setName}.has(_key${d})) {`,
75
+ ` errors.push({ message: 'must NOT have additional properties', path: \`${ctx.pathPrefix}/\${_key${d}}\` })`,
76
+ ` }`,
77
+ ` }`,
78
+ ];
79
+ };
50
80
  /**
51
81
  * Generates validation lines for a single property in an object schema.
52
- * Handles $ref delegation, enum checks, type checks, and string/number constraints.
82
+ * Handles $ref delegation, enum checks, type checks, string/number constraints,
83
+ * and recursion into inline nested objects.
53
84
  */
54
- const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
85
+ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
55
86
  if (!isSchemaObject(propSchema))
56
87
  return [];
57
- const raw = `obj[${JSON.stringify(key)}]`;
58
- const path = `\`\${_path}/${key}\``;
88
+ const raw = `${ctx.objVar}[${JSON.stringify(key)}]`;
89
+ const path = `\`${ctx.pathPrefix}/${key}\``;
90
+ // Missing-property errors report at the parent object's path. At the root
91
+ // that is the `_path` parameter itself; inside nested objects it is the
92
+ // parent's accumulated static path.
93
+ const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
59
94
  const lines = [];
60
95
  // $ref — delegate to the imported validator
61
96
  if (hasRef(propSchema)) {
62
97
  const ref = propSchema.$ref;
63
98
  const vName = validatorName(refToName(ref, suffix));
64
99
  if (isRequired) {
65
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
66
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
100
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
101
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
67
102
  lines.push(` } else {`);
68
103
  lines.push(` const _r = ${vName}(${raw}, ${path})`);
69
104
  lines.push(` if (_r !== true) errors.push(..._r.errors)`);
@@ -81,8 +116,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
81
116
  const instanceOf = getMjstInstanceOf(propSchema);
82
117
  if (instanceOf) {
83
118
  if (isRequired) {
84
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
85
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
119
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
120
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
86
121
  lines.push(` } else if (!(${raw} instanceof ${instanceOf})) {`);
87
122
  lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
88
123
  lines.push(` }`);
@@ -98,8 +133,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
98
133
  const primitive = getMjstPrimitive(propSchema);
99
134
  if (primitive) {
100
135
  if (isRequired) {
101
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
102
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
136
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
137
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
103
138
  lines.push(` } else if (typeof ${raw} !== "${primitive}") {`);
104
139
  lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
105
140
  lines.push(` }`);
@@ -116,8 +151,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
116
151
  const mismatch = constMismatchCondition(raw, propSchema.const);
117
152
  const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
118
153
  if (isRequired) {
119
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
120
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
154
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
155
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
121
156
  lines.push(` } else if (${mismatch}) {`);
122
157
  lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
123
158
  lines.push(` }`);
@@ -134,8 +169,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
134
169
  const allowed = JSON.stringify(propSchema.enum);
135
170
  const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
136
171
  if (isRequired) {
137
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
138
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
172
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
173
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
139
174
  lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`);
140
175
  lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
141
176
  lines.push(` }`);
@@ -153,8 +188,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
153
188
  const wrongType = wrongTypeCondition(raw, t);
154
189
  const typLabel = typeofString(t);
155
190
  if (isRequired) {
156
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
157
- lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`);
191
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
192
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
158
193
  if (wrongType) {
159
194
  lines.push(` } else if (${wrongType}) {`);
160
195
  lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
@@ -189,13 +224,20 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
189
224
  // Number constraints
190
225
  if (t === 'number' || t === 'integer') {
191
226
  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} })`);
227
+ // Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
228
+ const strict = hasStrictExclusiveMinimum(propSchema);
229
+ const op = strict ? '<=' : '<';
230
+ const rel = strict ? '>' : '>=';
231
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
232
+ lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
194
233
  lines.push(` }`);
195
234
  }
196
235
  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} })`);
236
+ const strict = hasStrictExclusiveMaximum(propSchema);
237
+ const op = strict ? '>=' : '>';
238
+ const rel = strict ? '<' : '<=';
239
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
240
+ lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
199
241
  lines.push(` }`);
200
242
  }
201
243
  if (hasExclusiveMinimum(propSchema)) {
@@ -221,7 +263,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
221
263
  const vName = validatorName(refToName(itemSchema.$ref, suffix));
222
264
  lines.push(` if (Array.isArray(${raw})) {`);
223
265
  lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
224
- lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/${key}/\${_i}\`)`);
266
+ lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
225
267
  lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
226
268
  lines.push(` }`);
227
269
  lines.push(` }`);
@@ -234,15 +276,100 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
234
276
  lines.push(` if (Array.isArray(${raw})) {`);
235
277
  lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
236
278
  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}\` })`);
279
+ lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
238
280
  lines.push(` }`);
239
281
  lines.push(` }`);
240
282
  }
241
283
  }
242
284
  }
285
+ // Inline nested object — recurse so the nested fields are actually
286
+ // validated. Without this only the "must be object" shape check above
287
+ // runs and everything inside the nested object silently passes.
288
+ if (t === 'object') {
289
+ lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
290
+ }
243
291
  }
244
292
  return lines;
245
293
  };
294
+ /**
295
+ * Generates the recursive checks for an inline nested object property, i.e. an
296
+ * object schema written directly under `properties` rather than referenced via
297
+ * `$ref` (those delegate to the referenced validator instead). The value is
298
+ * narrowed into its own block-scoped variable and each nested property runs
299
+ * through the same per-property generator, so nesting works to any depth.
300
+ */
301
+ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
302
+ if (!isSchemaObject(propSchema))
303
+ return [];
304
+ const child = {
305
+ objVar: `_obj${ctx.depth + 1}`,
306
+ pathPrefix: `${ctx.pathPrefix}/${key}`,
307
+ depth: ctx.depth + 1,
308
+ hoisted: ctx.hoisted,
309
+ };
310
+ const required = new Set(hasRequired(propSchema) ? propSchema.required : []);
311
+ const properties = hasProperties(propSchema) ? propSchema.properties : {};
312
+ const innerLines = [];
313
+ for (const [childKey, childSchema] of Object.entries(properties)) {
314
+ innerLines.push(...generatePropertyChecks(childKey, childSchema, required.has(childKey), suffix, child));
315
+ }
316
+ innerLines.push(...generateStrictKeyChecks(propSchema, child));
317
+ if (innerLines.length === 0)
318
+ return [];
319
+ // The shape check for the property itself already ran (or the property is
320
+ // optional), so re-guard here instead of assuming the value is an object.
321
+ return [
322
+ ` if (typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})) {`,
323
+ ` const ${child.objVar} = ${raw} as Record<string, unknown>`,
324
+ ...innerLines.map((line) => ` ${line}`),
325
+ ` }`,
326
+ ];
327
+ };
328
+ /**
329
+ * Generates the `propertyNames` loop: every object key is a string, so we apply
330
+ * the string-relevant constraints of the subschema (or delegate to a `$ref`'s
331
+ * validator). This keeps the generator in step with the interpreter, which runs
332
+ * the whole subschema against each key — not just the `pattern` form.
333
+ */
334
+ const generatePropertyNameChecks = (nameSchema, suffix) => {
335
+ if (!isSchemaObject(nameSchema))
336
+ return [];
337
+ const at = '`${_path}/${_name}`';
338
+ const checks = [];
339
+ if (hasRef(nameSchema)) {
340
+ const vName = validatorName(refToName(nameSchema.$ref, suffix));
341
+ checks.push(` const _nr = ${vName}(_name, ${at})`);
342
+ checks.push(` if (_nr !== true) errors.push(..._nr.errors)`);
343
+ }
344
+ else {
345
+ if (hasPattern(nameSchema)) {
346
+ const re = escapeRegexPattern(nameSchema.pattern);
347
+ const msg = JSON.stringify(`property name must match pattern ${nameSchema.pattern}`);
348
+ checks.push(` if (!/${re}/.test(_name)) errors.push({ message: ${msg}, path: ${at} })`);
349
+ }
350
+ if (hasMinLength(nameSchema)) {
351
+ const msg = JSON.stringify(`property name must have at least ${nameSchema.minLength} characters`);
352
+ checks.push(` if (_name.length < ${nameSchema.minLength}) errors.push({ message: ${msg}, path: ${at} })`);
353
+ }
354
+ if (hasMaxLength(nameSchema)) {
355
+ const msg = JSON.stringify(`property name must have at most ${nameSchema.maxLength} characters`);
356
+ checks.push(` if (_name.length > ${nameSchema.maxLength}) errors.push({ message: ${msg}, path: ${at} })`);
357
+ }
358
+ if (hasEnum(nameSchema)) {
359
+ const allowed = JSON.stringify(nameSchema.enum);
360
+ const label = nameSchema.enum.map((v) => JSON.stringify(v)).join(', ');
361
+ const msg = JSON.stringify(`property name must be one of: ${label}`);
362
+ checks.push(` if (!(${allowed} as unknown[]).includes(_name)) errors.push({ message: ${msg}, path: ${at} })`);
363
+ }
364
+ if (hasConst(nameSchema)) {
365
+ const msg = JSON.stringify(`property name must be ${JSON.stringify(nameSchema.const)}`);
366
+ checks.push(` if (_name !== ${JSON.stringify(nameSchema.const)}) errors.push({ message: ${msg}, path: ${at} })`);
367
+ }
368
+ }
369
+ if (checks.length === 0)
370
+ return [];
371
+ return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
372
+ };
246
373
  /**
247
374
  * Generates a validator function body for an object schema, checking each
248
375
  * property's presence and type and collecting all errors.
@@ -251,9 +378,10 @@ const generateObjectValidator = (schema, typeName, suffix) => {
251
378
  const vName = validatorName(typeName);
252
379
  const required = new Set(hasRequired(schema) ? schema.required : []);
253
380
  const properties = hasProperties(schema) ? schema.properties : {};
381
+ const ctx = createRootContext();
254
382
  const propertyLines = [];
255
383
  for (const [key, propSchema] of Object.entries(properties)) {
256
- const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix);
384
+ const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix, ctx);
257
385
  if (checks.length > 0) {
258
386
  propertyLines.push(...checks);
259
387
  }
@@ -269,6 +397,8 @@ const generateObjectValidator = (schema, typeName, suffix) => {
269
397
  propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
270
398
  propertyLines.push(` }`);
271
399
  }
400
+ // additionalProperties: false rejects every key not declared in properties
401
+ propertyLines.push(...generateStrictKeyChecks(schema, ctx));
272
402
  // dependentRequired — when a trigger property is present, its dependencies must be too.
273
403
  if (hasDependentRequired(schema)) {
274
404
  for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
@@ -282,20 +412,17 @@ const generateObjectValidator = (schema, typeName, suffix) => {
282
412
  }
283
413
  }
284
414
  }
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(` }`);
415
+ // propertyNames — every key (always a string) must satisfy the subschema. This
416
+ // mirrors the interpreter, which runs the full subschema against each key.
417
+ if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
418
+ propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
295
419
  }
296
420
  const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '';
421
+ // Hoisted statements (e.g. known-keys Sets) come first so every call of the
422
+ // validator reuses them instead of rebuilding them.
423
+ const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
297
424
  return [
298
- `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
425
+ `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
299
426
  ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
300
427
  ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
301
428
  ` }`,
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -24,8 +24,7 @@
24
24
  "url": "https://github.com/amritk/mjst/issues"
25
25
  },
26
26
  "files": [
27
- "dist",
28
- "src"
27
+ "dist"
29
28
  ],
30
29
  "publishConfig": {
31
30
  "access": "public"
@@ -33,23 +32,27 @@
33
32
  "scripts": {
34
33
  "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
35
34
  "types:check": "tsgo -p . --noEmit",
36
- "test": "NODE_ENV=production vitest run --root ../.. generate-validators"
35
+ "test": "NODE_ENV=production vitest run --root ../.. generate-validators",
36
+ "bench": "bun run ./bench/run.ts"
37
37
  },
38
38
  "imports": {
39
39
  "#generators/*": "./src/generators/*.ts"
40
40
  },
41
41
  "exports": {
42
42
  ".": {
43
- "development": "./src/index.ts",
44
43
  "default": "./dist/index.js",
45
44
  "types": "./dist/index.d.ts"
46
45
  }
47
46
  },
48
47
  "dependencies": {
49
48
  "json-schema-typed": "^8.0.1",
50
- "@amritk/helpers": "0.8.0"
49
+ "@amritk/helpers": "0.9.0"
51
50
  },
52
51
  "devDependencies": {
53
- "@scalar/openapi-parser": "^0.26.1"
52
+ "@scalar/openapi-parser": "^0.26.1",
53
+ "@sinclair/typebox": "^0.34.49",
54
+ "ajv": "^8.17.1",
55
+ "ajv-formats": "^3.0.1",
56
+ "zod": "^4.4.3"
54
57
  }
55
58
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../../src/generators/build-schema.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAIjE;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAmBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,eACnB,UAAU,gBACR,MAAM,0BAEnB,OAAO,CAAC,aAAa,EAAE,CAuBzB,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"collect-validator-imports.d.ts","sourceRoot":"","sources":["../../src/generators/collect-validator-imports.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE;;GAEG;AACH,KAAK,8BAA8B,GAAG;IACpC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACrC;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;IACzD;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAoED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,WAAY,UAAU,YAAY,8BAA8B,KAAG,MAAM,EA6B5G,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"generate-files.d.ts","sourceRoot":"","sources":["../../src/generators/generate-files.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAKjE;;GAEG;AACH,KAAK,4BAA4B,GAAG;IAClC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB;;OAEG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,qBAAqB,WACxB,UAAU,YACR,MAAM,YACN,4BAA4B,KACrC,MA0BF,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"generate-validator-function.d.ts","sourceRoot":"","sources":["../../src/generators/generate-validator-function.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AA+ejE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,yBAAyB,WAAY,UAAU,YAAY,MAAM,sBAAgB,MAM7F,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA"}