@amritk/generate-validators 0.11.6 → 0.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -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) | **~
|
|
133
|
-
| order (nested + array) | **~
|
|
134
|
-
| assert-loose | **~
|
|
135
|
-
| assert-strict | **~
|
|
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)
|
|
140
|
-
typia on
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.3–0.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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
lines.push(`
|
|
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
|
-
|
|
360
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.7",
|
|
4
4
|
"description": "Generate TypeScript validation functions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"json-schema-typed": "^8.0.1",
|
|
49
|
-
"@amritk/helpers": "0.13.
|
|
49
|
+
"@amritk/helpers": "0.13.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@amritk/runtime-validators": "0.7.
|
|
52
|
+
"@amritk/runtime-validators": "0.7.1",
|
|
53
53
|
"@ryoppippi/unplugin-typia": "^2.6.5",
|
|
54
54
|
"@scalar/openapi-parser": "^0.26.1",
|
|
55
55
|
"@sinclair/typebox": "^0.34.49",
|