@amritk/generate-validators 0.5.1 → 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,6 +1,7 @@
1
+ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
1
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
2
3
  import { refToName } from '@amritk/helpers/ref-to-name';
3
- import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, 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';
4
5
  /**
5
6
  * Derives the validator function name from a type name.
6
7
  * e.g. "InfoObject" → "validateInfoObject"
@@ -14,6 +15,19 @@ const typeofString = (type) => {
14
15
  return 'number';
15
16
  return type;
16
17
  };
18
+ /**
19
+ * Generates the inline condition that is TRUE when `accessor` does NOT equal the
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).
24
+ */
25
+ const constMismatchCondition = (accessor, value) => {
26
+ if (value === null || typeof value !== 'object') {
27
+ return `${accessor} !== ${JSON.stringify(value)}`;
28
+ }
29
+ return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
30
+ };
17
31
  /**
18
32
  * Generates the inline condition that is TRUE when a value is the wrong type.
19
33
  */
@@ -34,23 +48,57 @@ const wrongTypeCondition = (accessor, type) => {
34
48
  return '';
35
49
  }
36
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
+ };
37
80
  /**
38
81
  * Generates validation lines for a single property in an object schema.
39
- * 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.
40
84
  */
41
- const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
85
+ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
42
86
  if (!isSchemaObject(propSchema))
43
87
  return [];
44
- const raw = `obj[${JSON.stringify(key)}]`;
45
- 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}\``;
46
94
  const lines = [];
47
95
  // $ref — delegate to the imported validator
48
96
  if (hasRef(propSchema)) {
49
97
  const ref = propSchema.$ref;
50
98
  const vName = validatorName(refToName(ref, suffix));
51
99
  if (isRequired) {
52
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
53
- 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} })`);
54
102
  lines.push(` } else {`);
55
103
  lines.push(` const _r = ${vName}(${raw}, ${path})`);
56
104
  lines.push(` if (_r !== true) errors.push(..._r.errors)`);
@@ -68,8 +116,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
68
116
  const instanceOf = getMjstInstanceOf(propSchema);
69
117
  if (instanceOf) {
70
118
  if (isRequired) {
71
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
72
- 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} })`);
73
121
  lines.push(` } else if (!(${raw} instanceof ${instanceOf})) {`);
74
122
  lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
75
123
  lines.push(` }`);
@@ -85,8 +133,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
85
133
  const primitive = getMjstPrimitive(propSchema);
86
134
  if (primitive) {
87
135
  if (isRequired) {
88
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
89
- 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} })`);
90
138
  lines.push(` } else if (typeof ${raw} !== "${primitive}") {`);
91
139
  lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
92
140
  lines.push(` }`);
@@ -98,13 +146,31 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
98
146
  }
99
147
  return lines;
100
148
  }
149
+ // const — value must equal the fixed value exactly
150
+ if (hasConst(propSchema)) {
151
+ const mismatch = constMismatchCondition(raw, propSchema.const);
152
+ const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
153
+ if (isRequired) {
154
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
155
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
156
+ lines.push(` } else if (${mismatch}) {`);
157
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
158
+ lines.push(` }`);
159
+ }
160
+ else {
161
+ lines.push(` if (${raw} !== undefined && ${mismatch}) {`);
162
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
163
+ lines.push(` }`);
164
+ }
165
+ return lines;
166
+ }
101
167
  // enum
102
168
  if (hasEnum(propSchema)) {
103
169
  const allowed = JSON.stringify(propSchema.enum);
104
170
  const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
105
171
  if (isRequired) {
106
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
107
- 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} })`);
108
174
  lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`);
109
175
  lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
110
176
  lines.push(` }`);
@@ -122,8 +188,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
122
188
  const wrongType = wrongTypeCondition(raw, t);
123
189
  const typLabel = typeofString(t);
124
190
  if (isRequired) {
125
- lines.push(` if (!(${JSON.stringify(key)} in obj)) {`);
126
- 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} })`);
127
193
  if (wrongType) {
128
194
  lines.push(` } else if (${wrongType}) {`);
129
195
  lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
@@ -138,8 +204,10 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
138
204
  // String constraints
139
205
  if (t === 'string') {
140
206
  if (hasPattern(propSchema)) {
141
- lines.push(` if (typeof ${raw} === 'string' && !/${propSchema.pattern}/.test(${raw})) {`);
142
- lines.push(` errors.push({ message: 'must match pattern ${propSchema.pattern}', path: ${path} })`);
207
+ const re = escapeRegexPattern(propSchema.pattern);
208
+ const msg = JSON.stringify(`must match pattern ${propSchema.pattern}`);
209
+ lines.push(` if (typeof ${raw} === 'string' && !/${re}/.test(${raw})) {`);
210
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
143
211
  lines.push(` }`);
144
212
  }
145
213
  if (hasMinLength(propSchema)) {
@@ -156,13 +224,20 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
156
224
  // Number constraints
157
225
  if (t === 'number' || t === 'integer') {
158
226
  if (hasMinimum(propSchema)) {
159
- lines.push(` if (typeof ${raw} === 'number' && ${raw} < ${propSchema.minimum}) {`);
160
- 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} })`);
161
233
  lines.push(` }`);
162
234
  }
163
235
  if (hasMaximum(propSchema)) {
164
- lines.push(` if (typeof ${raw} === 'number' && ${raw} > ${propSchema.maximum}) {`);
165
- 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} })`);
166
241
  lines.push(` }`);
167
242
  }
168
243
  if (hasExclusiveMinimum(propSchema)) {
@@ -188,7 +263,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
188
263
  const vName = validatorName(refToName(itemSchema.$ref, suffix));
189
264
  lines.push(` if (Array.isArray(${raw})) {`);
190
265
  lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
191
- 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}\`)`);
192
267
  lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
193
268
  lines.push(` }`);
194
269
  lines.push(` }`);
@@ -201,15 +276,100 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
201
276
  lines.push(` if (Array.isArray(${raw})) {`);
202
277
  lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
203
278
  lines.push(` const _item = ${raw}[_i]`);
204
- 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}\` })`);
205
280
  lines.push(` }`);
206
281
  lines.push(` }`);
207
282
  }
208
283
  }
209
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
+ }
210
291
  }
211
292
  return lines;
212
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
+ };
213
373
  /**
214
374
  * Generates a validator function body for an object schema, checking each
215
375
  * property's presence and type and collecting all errors.
@@ -218,9 +378,10 @@ const generateObjectValidator = (schema, typeName, suffix) => {
218
378
  const vName = validatorName(typeName);
219
379
  const required = new Set(hasRequired(schema) ? schema.required : []);
220
380
  const properties = hasProperties(schema) ? schema.properties : {};
381
+ const ctx = createRootContext();
221
382
  const propertyLines = [];
222
383
  for (const [key, propSchema] of Object.entries(properties)) {
223
- const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix);
384
+ const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix, ctx);
224
385
  if (checks.length > 0) {
225
386
  propertyLines.push(...checks);
226
387
  }
@@ -236,9 +397,32 @@ const generateObjectValidator = (schema, typeName, suffix) => {
236
397
  propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
237
398
  propertyLines.push(` }`);
238
399
  }
400
+ // additionalProperties: false rejects every key not declared in properties
401
+ propertyLines.push(...generateStrictKeyChecks(schema, ctx));
402
+ // dependentRequired — when a trigger property is present, its dependencies must be too.
403
+ if (hasDependentRequired(schema)) {
404
+ for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
405
+ if (!Array.isArray(deps))
406
+ continue;
407
+ for (const dep of deps) {
408
+ const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`);
409
+ propertyLines.push(` if (${JSON.stringify(trigger)} in obj && !(${JSON.stringify(dep)} in obj)) {`);
410
+ propertyLines.push(` errors.push({ message: ${msg}, path: _path })`);
411
+ propertyLines.push(` }`);
412
+ }
413
+ }
414
+ }
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));
419
+ }
239
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` : '';
240
424
  return [
241
- `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
425
+ `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
242
426
  ` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
243
427
  ` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
244
428
  ` }`,
@@ -291,6 +475,19 @@ const generateScalarValidator = (schema, typeName, suffix) => {
291
475
  `}`,
292
476
  ].join('\n');
293
477
  }
478
+ // Top-level const
479
+ if (hasConst(schema)) {
480
+ const mismatch = constMismatchCondition('input', schema.const);
481
+ const msg = JSON.stringify(`must be ${JSON.stringify(schema.const)}`);
482
+ return [
483
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
484
+ ` if (${mismatch}) {`,
485
+ ` return { valid: false, errors: [{ message: ${msg}, path: _path }] }`,
486
+ ` }`,
487
+ ` return true`,
488
+ `}`,
489
+ ].join('\n');
490
+ }
294
491
  // Top-level enum
295
492
  if (hasEnum(schema)) {
296
493
  const allowed = JSON.stringify(schema.enum);
@@ -330,8 +527,10 @@ const generateScalarValidator = (schema, typeName, suffix) => {
330
527
  const constraintLines = [];
331
528
  if (t === 'string') {
332
529
  if (hasPattern(schema)) {
333
- constraintLines.push(` if (typeof input === 'string' && !/${schema.pattern}/.test(input)) {`);
334
- constraintLines.push(` errors.push({ message: 'must match pattern ${schema.pattern}', path: _path })`);
530
+ const re = escapeRegexPattern(schema.pattern);
531
+ const msg = JSON.stringify(`must match pattern ${schema.pattern}`);
532
+ constraintLines.push(` if (typeof input === 'string' && !/${re}/.test(input)) {`);
533
+ constraintLines.push(` errors.push({ message: ${msg}, path: _path })`);
335
534
  constraintLines.push(` }`);
336
535
  }
337
536
  if (hasMinLength(schema)) {
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.5.1",
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.7.1"
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":"AAsBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAqajE;;;;;;;;;;;;;;;;;;;;;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"}