@amritk/generate-validators 0.11.6 → 0.11.8

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 amritk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -129,20 +129,21 @@ Measured on Bun 1.3 (Linux x64), validating valid input at steady state:
129
129
 
130
130
  | schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
131
131
  |:--|--:|--:|--:|--:|--:|
132
- | small (4 fields) | **~22M** ops/s | ~4.2M ops/s | ~7.0M ops/s | ~4.0M ops/s | ~1.8M ops/s |
133
- | order (nested + array) | **~6.9M** ops/s | ~1.7M ops/s | ~2.5M ops/s | ~1.7M ops/s | ~0.4M ops/s |
134
- | assert-loose | **~110M** ops/s | ~100M ops/s | ~31M ops/s | ~41M ops/s | ~3.2M ops/s |
135
- | assert-strict | **~98M** ops/s | ~82M ops/s | ~13M ops/s | ~28M ops/s | ~1.1M ops/s |
132
+ | small (4 fields) | **~48M** ops/s | ~5M ops/s | ~10.5M ops/s | ~5.3M ops/s | ~2M ops/s |
133
+ | order (nested + array) | **~7.8M** ops/s | ~2.2M ops/s | ~3.5M ops/s | ~2.1M ops/s | ~0.5M ops/s |
134
+ | assert-loose | **~184M** ops/s | ~183M ops/s | ~45M ops/s | ~63M ops/s | ~3.8M ops/s |
135
+ | assert-strict | **~162M** ops/s | ~148M ops/s | ~22M ops/s | ~38M ops/s | ~1.3M ops/s |
136
136
 
137
137
  The `assert-loose` / `assert-strict` rows are the exact shape used by
138
138
  [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
139
- (seven scalar roots plus a nested object); the boolean guard lets mjst edge past
140
- typia on both, with and without `additionalProperties: false`. (typia and
141
- TypeBox still win the *invalid* path, where they bail on the first error rather
142
- than collecting a full error list.)
143
-
144
- Preparing a validator costs ~0.1 ms for mjst codegen and ~0.05–0.12 ms for a
145
- TypeBox `TypeCompiler` compile, versus ~810 ms for an Ajv compile. Every library
139
+ (seven scalar roots plus a nested object): the boolean guard puts mjst clearly
140
+ ahead of typia on `assert-strict` (with `additionalProperties: false`) and
141
+ neck-and-neck with it on `assert-loose` the two trade the lead run-to-run
142
+ within noise. (typia and TypeBox still win the *invalid* path, where they bail on
143
+ the first error rather than collecting a full error list.)
144
+
145
+ Preparing a validator costs ~0.30.6 ms for mjst codegen and ~0.05–0.2 ms for a
146
+ TypeBox `TypeCompiler` compile, versus ~9–12 ms for an Ajv compile. Every library
146
147
  agrees on every verdict; parity is asserted before timing (TypeBox is given
147
148
  uuid/email format checkers so every library does the same work). Each library is
148
149
  timed in an isolated process over a pool of distinct inputs, reporting the median
@@ -54,7 +54,11 @@ const collectDirectRefs = (value, refs = []) => {
54
54
  // generated files, not inlined by this validator. `collectDirectRefs`
55
55
  // self-guards on non-objects, so a keyword that is a boolean or missing is a
56
56
  // harmless no-op.
57
- const subSchemaMaps = ['properties', 'patternProperties', 'dependentSchemas'];
57
+ // `dependencies` (draft-07) is dual-form: a string array (dependentRequired) or
58
+ // a subschema (dependentSchemas). The emitter delegates the schema form via
59
+ // `validateX`, so a `$ref` inside it must be imported; the string-array form is
60
+ // a harmless no-op here (its values are strings, not schemas).
61
+ const subSchemaMaps = ['properties', 'patternProperties', 'dependentSchemas', 'dependencies'];
58
62
  for (const mapKey of subSchemaMaps) {
59
63
  const map = schema[mapKey];
60
64
  if (typeof map === 'object' && map !== null && !Array.isArray(map)) {
@@ -105,6 +105,21 @@ const getTypeArray = (schema) => {
105
105
  return schema.type;
106
106
  };
107
107
  const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
108
+ /**
109
+ * Renders a schema-controlled property name as a static error-path segment.
110
+ *
111
+ * The name is appended to a backtick template-literal path (`` `${_path}/…` ``),
112
+ * so two independent escapings apply. First the JSON Pointer escape (`~`→`~0`,
113
+ * `/`→`~1`, `~` first) so a key containing `/` or `~` reads back unambiguously —
114
+ * matching the paths the runtime-validators interpreter emits. Then a
115
+ * template-literal escape of `` ` ``, `\`, and `$`, so a key like `` a`b `` or
116
+ * `${x}` cannot terminate the literal (a build failure) or inject an
117
+ * interpolation (a runtime `ReferenceError` / arbitrary expression).
118
+ */
119
+ const pointerSegment = (key) => key
120
+ .replace(/~/g, '~0')
121
+ .replace(/\//g, '~1')
122
+ .replace(/[\\`$]/g, '\\$&');
108
123
  /**
109
124
  * Returns the `patternProperties` regex sources, or an empty array when the
110
125
  * schema declares none. The keys of `patternProperties` are the patterns.
@@ -183,31 +198,54 @@ const generateMissingRequiredChecks = (schema, ctx) => {
183
198
  * and recursion into inline nested objects.
184
199
  */
185
200
  const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
186
- if (!isSchemaObject(propSchema))
201
+ if (!isSchemaObject(propSchema)) {
202
+ // A boolean `true` (accept-anything) schema carries no shape checks, but a
203
+ // required key must still be present. `false` never validates a present value,
204
+ // which the strict-key / additionalProperties path handles; here we only need
205
+ // to enforce presence for `true`.
206
+ if (isRequired && propSchema === true) {
207
+ const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
208
+ return [
209
+ ` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`,
210
+ ` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`,
211
+ ` }`,
212
+ ];
213
+ }
187
214
  return [];
215
+ }
188
216
  const raw = `${ctx.objVar}[${JSON.stringify(key)}]`;
189
- const path = `\`${ctx.pathPrefix}/${key}\``;
217
+ const path = `\`${ctx.pathPrefix}/${pointerSegment(key)}\``;
190
218
  // Missing-property errors report at the parent object's path. At the root
191
219
  // that is the `_path` parameter itself; inside nested objects it is the
192
220
  // parent's accumulated static path.
193
221
  const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
194
222
  const lines = [];
195
- // $ref — delegate to the imported validator
223
+ // $ref — delegate to the imported validator. Per 2020-12, sibling keywords
224
+ // alongside `$ref` still apply to the same value, so any constraint/combinator
225
+ // siblings (e.g. `{ $ref, minLength: 5 }`) run after the delegation. A bare
226
+ // `{ $ref }` produces no siblings, leaving the output unchanged.
196
227
  if (hasRef(propSchema)) {
197
228
  const ref = propSchema.$ref;
198
229
  const vName = validatorName(refToName(ref, suffix));
230
+ const siblings = [
231
+ ...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx),
232
+ ...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx),
233
+ ];
234
+ const delegate = [
235
+ ` const _r = ${vName}(${raw}, ${path})`,
236
+ ` if (_r !== true) errors.push(..._r.errors)`,
237
+ ...siblings,
238
+ ];
199
239
  if (isRequired) {
200
240
  lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
201
241
  lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
202
242
  lines.push(` } else {`);
203
- lines.push(` const _r = ${vName}(${raw}, ${path})`);
204
- lines.push(` if (_r !== true) errors.push(..._r.errors)`);
243
+ lines.push(...delegate);
205
244
  lines.push(` }`);
206
245
  }
207
246
  else {
208
247
  lines.push(` if (${raw} !== undefined) {`);
209
- lines.push(` const _r = ${vName}(${raw}, ${path})`);
210
- lines.push(` if (_r !== true) errors.push(..._r.errors)`);
248
+ lines.push(...delegate);
211
249
  lines.push(` }`);
212
250
  }
213
251
  return lines;
@@ -347,20 +385,32 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
347
385
  ...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx),
348
386
  ...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx),
349
387
  ];
350
- if (extraLines.length > 0) {
351
- if (!hasType(propSchema) && isRequired) {
352
- // No `type` to anchor a missing-property check, so enforce presence here.
353
- lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
354
- lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
355
- lines.push(` } else {`);
388
+ if (hasType(propSchema)) {
389
+ // Presence was already enforced in the `hasType` branch; only wrap the
390
+ // combinator siblings so they run when the value is present.
391
+ if (extraLines.length > 0) {
392
+ lines.push(` if (${raw} !== undefined) {`);
356
393
  lines.push(...extraLines);
357
394
  lines.push(` }`);
358
395
  }
359
- else {
360
- lines.push(` if (${raw} !== undefined) {`);
396
+ }
397
+ else if (isRequired) {
398
+ // Type-less required property. Presence must be enforced even when the schema
399
+ // contributes no other checks (e.g. `{}` — an accept-anything schema), so a
400
+ // missing required key is still an error. Any extra checks run in the `else`.
401
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
402
+ lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
403
+ if (extraLines.length > 0) {
404
+ lines.push(` } else {`);
361
405
  lines.push(...extraLines);
362
- lines.push(` }`);
363
406
  }
407
+ lines.push(` }`);
408
+ }
409
+ else if (extraLines.length > 0) {
410
+ // Type-less optional property: run any checks only when the value is present.
411
+ lines.push(` if (${raw} !== undefined) {`);
412
+ lines.push(...extraLines);
413
+ lines.push(` }`);
364
414
  }
365
415
  return lines;
366
416
  };
@@ -485,7 +535,16 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
485
535
  hasMaxItems(propSchema) ||
486
536
  (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) ||
487
537
  isSchemaObject(sp['contains']) ||
488
- Array.isArray(sp['prefixItems'])) {
538
+ Array.isArray(sp['prefixItems']) ||
539
+ (sp['items'] === false && !Array.isArray(sp['prefixItems']))) {
540
+ // `items: false` with no `prefixItems` forbids every element, so the array
541
+ // must be empty. (With `prefixItems`, the tuple block below caps the length
542
+ // instead.) Without this the constraint was silently ignored.
543
+ if (sp['items'] === false && !Array.isArray(sp['prefixItems'])) {
544
+ lines.push(` if (Array.isArray(${raw}) && ${raw}.length > 0) {`);
545
+ lines.push(` errors.push({ message: 'must NOT have more than 0 items', path: ${path} })`);
546
+ lines.push(` }`);
547
+ }
489
548
  if (hasMinItems(propSchema)) {
490
549
  lines.push(` if (Array.isArray(${raw}) && ${raw}.length < ${propSchema.minItems}) {`);
491
550
  lines.push(` errors.push({ message: 'must have at least ${propSchema.minItems} items', path: ${path} })`);
@@ -778,7 +837,7 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
778
837
  // When `key` is empty the value is located AT `ctx.pathPrefix` already (e.g. an
779
838
  // inline object reached through a combinator branch or a dynamic-key value), so
780
839
  // appending `/${key}` would emit a spurious `//` or trailing `/` in error paths.
781
- pathPrefix: key === '' ? ctx.pathPrefix : `${ctx.pathPrefix}/${key}`,
840
+ pathPrefix: key === '' ? ctx.pathPrefix : `${ctx.pathPrefix}/${pointerSegment(key)}`,
782
841
  depth: ctx.depth + 1,
783
842
  hoisted: ctx.hoisted,
784
843
  };
@@ -1566,10 +1625,44 @@ const generateScalarValidator = (schema, typeName, suffix) => {
1566
1625
  // (exactly one) and inline branches included, not just `$ref` branches.
1567
1626
  if (hasAllOf(schema) || hasAnyOf(schema) || hasOneOf(schema) || 'not' in schema || 'if' in schema) {
1568
1627
  const ctx = createRootContext();
1569
- const checks = generateCombinatorChecks('', 'input', '`${_path}`', schema, suffix, ctx);
1628
+ const checks = [];
1629
+ // The root path expression the shared emitters use, as a template literal body.
1630
+ const rootPath = '`${_path}`';
1631
+ // A `type` (and its sibling value constraints) alongside a combinator still
1632
+ // applies — the value must satisfy BOTH. Emit the type/constraint checks first,
1633
+ // then the combinator checks, so a schema like `{ type: 'string', not: {…} }`
1634
+ // or `{ type: 'number', minimum: 10, allOf: [{ maximum: 100 }] }` no longer
1635
+ // drops the `type` check and its siblings.
1636
+ const rootTypeArray = getTypeArray(schema);
1637
+ if (rootTypeArray) {
1638
+ const allWrong = rootTypeArray
1639
+ .map((t) => wrongTypeCondition('input', t))
1640
+ .filter((c) => c !== '')
1641
+ .map((c) => `(${c})`)
1642
+ .join(' && ');
1643
+ if (allWrong) {
1644
+ const label = rootTypeArray.map((t) => typeofString(t)).join(' or ');
1645
+ checks.push(` if (${allWrong}) {`);
1646
+ checks.push(` errors.push({ message: ${JSON.stringify(`must be ${label}`)}, path: ${rootPath} })`);
1647
+ checks.push(` }`);
1648
+ }
1649
+ checks.push(...generateConstraintChecks('', 'input', rootPath, schema, suffix, ctx));
1650
+ }
1651
+ else if (hasType(schema)) {
1652
+ const t = schema.type;
1653
+ const wrongType = wrongTypeCondition('input', t);
1654
+ if (wrongType) {
1655
+ checks.push(` if (${wrongType}) {`);
1656
+ checks.push(` errors.push({ message: 'must be ${typeofString(t)}', path: ${rootPath} })`);
1657
+ checks.push(` }`);
1658
+ }
1659
+ checks.push(...generateConstraintChecks('', 'input', rootPath, schema, suffix, ctx));
1660
+ }
1661
+ checks.push(...generateCombinatorChecks('', 'input', rootPath, schema, suffix, ctx));
1570
1662
  const body = checks.join('\n').replaceAll('errors.push(', '(errors ??= []).push(');
1663
+ const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
1571
1664
  return [
1572
- `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
1665
+ `${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
1573
1666
  ` let errors: ValidationError[] | undefined`,
1574
1667
  body,
1575
1668
  ` return errors !== undefined ? { valid: false, errors } : true`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.11.6",
3
+ "version": "0.11.8",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -40,16 +40,16 @@
40
40
  },
41
41
  "exports": {
42
42
  ".": {
43
- "default": "./dist/index.js",
44
- "types": "./dist/index.d.ts"
43
+ "types": "./dist/index.d.ts",
44
+ "default": "./dist/index.js"
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
48
  "json-schema-typed": "^8.0.1",
49
- "@amritk/helpers": "0.13.0"
49
+ "@amritk/helpers": "0.13.2"
50
50
  },
51
51
  "devDependencies": {
52
- "@amritk/runtime-validators": "0.7.0",
52
+ "@amritk/runtime-validators": "0.7.2",
53
53
  "@ryoppippi/unplugin-typia": "^2.6.5",
54
54
  "@scalar/openapi-parser": "^0.26.1",
55
55
  "@sinclair/typebox": "^0.34.49",