@amritk/generate-validators 0.8.0 → 0.10.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
|
@@ -95,33 +95,39 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
|
|
|
95
95
|
## Benchmarks
|
|
96
96
|
|
|
97
97
|
Generated validators are straight-line, monomorphic TypeScript with no generic
|
|
98
|
-
dispatch.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
|
110
|
-
|
|
111
|
-
|
|
|
112
|
-
|
|
|
98
|
+
dispatch. The exported `validateX` is split into a hot and a cold half: on the
|
|
99
|
+
happy path it runs a single allocation-free boolean guard — a pure `&&` chain of
|
|
100
|
+
`typeof` checks (plus an `Object.keys().length` count when an object is closed
|
|
101
|
+
with `additionalProperties: false`) — and `return true`s straight away, only
|
|
102
|
+
calling a separate error-collecting function when something is actually wrong.
|
|
103
|
+
Keeping the hot function tiny lets V8 optimise it aggressively, so a valid-input
|
|
104
|
+
check beats every other library measured — including the build-time transformer
|
|
105
|
+
typia — while still emitting full JSON-Pointer errors for invalid input, and
|
|
106
|
+
emitting the validator stays far cheaper than compiling a schema at startup.
|
|
107
|
+
Measured on Bun 1.3 (Linux x64), validating valid input at steady state:
|
|
108
|
+
|
|
109
|
+
| schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
|
|
110
|
+
|:--|--:|--:|--:|--:|--:|
|
|
111
|
+
| small (4 fields) | **~22M** ops/s | ~4.2M ops/s | ~7.0M ops/s | ~4.0M ops/s | ~1.8M ops/s |
|
|
112
|
+
| order (nested + array) | **~6.9M** ops/s | ~1.7M ops/s | ~2.5M ops/s | ~1.7M ops/s | ~0.4M ops/s |
|
|
113
|
+
| assert-loose | **~110M** ops/s | ~100M ops/s | ~31M ops/s | ~41M ops/s | ~3.2M ops/s |
|
|
114
|
+
| assert-strict | **~98M** ops/s | ~82M ops/s | ~13M ops/s | ~28M ops/s | ~1.1M ops/s |
|
|
113
115
|
|
|
114
116
|
The `assert-loose` / `assert-strict` rows are the exact shape used by
|
|
115
117
|
[`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
|
|
116
|
-
(seven scalar roots plus a nested object); the boolean guard lets mjst edge
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
(seven scalar roots plus a nested object); the boolean guard lets mjst edge past
|
|
119
|
+
typia on both, with and without `additionalProperties: false`. (typia and
|
|
120
|
+
TypeBox still win the *invalid* path, where they bail on the first error rather
|
|
121
|
+
than collecting a full error list.)
|
|
119
122
|
|
|
120
123
|
Preparing a validator costs ~0.1 ms for mjst codegen and ~0.05–0.12 ms for a
|
|
121
|
-
TypeBox `TypeCompiler` compile, versus ~8–10 ms for an Ajv compile.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
TypeBox `TypeCompiler` compile, versus ~8–10 ms for an Ajv compile. Every library
|
|
125
|
+
agrees on every verdict; parity is asserted before timing (TypeBox is given
|
|
126
|
+
uuid/email format checkers so every library does the same work). Each library is
|
|
127
|
+
timed in an isolated process over a pool of distinct inputs, reporting the median
|
|
128
|
+
of many trials — so the optimiser can't hoist or eliminate the work and the
|
|
129
|
+
numbers stay reproducible. Micro-benchmark figures vary by machine and runtime —
|
|
130
|
+
reproduce with:
|
|
125
131
|
|
|
126
132
|
```bash
|
|
127
133
|
bun run bench
|
|
@@ -25,7 +25,8 @@ type GenerateValidatorFileOptions = {
|
|
|
25
25
|
* - Imports for the ValidationResult/ValidationError types
|
|
26
26
|
* - Imports for any $ref types and their validator functions
|
|
27
27
|
* - The exported TypeScript type definition
|
|
28
|
-
* - The exported validator function
|
|
28
|
+
* - The exported validator function (`validateX`, rich `ValidationResult`)
|
|
29
|
+
* - The exported boolean type-guard (`isX`, a flat `input is X` predicate)
|
|
29
30
|
*
|
|
30
31
|
* @example
|
|
31
32
|
* ```typescript
|
|
@@ -38,6 +39,7 @@ type GenerateValidatorFileOptions = {
|
|
|
38
39
|
* // import type { ValidationResult, ValidationError } from './validation-result'
|
|
39
40
|
* // export type Info = { title: string }
|
|
40
41
|
* // export const validateInfo = (input: unknown, _path = ''): ValidationResult => { ... }
|
|
42
|
+
* // export const isInfo = (input: unknown): input is Info => { ... }
|
|
41
43
|
* ```
|
|
42
44
|
*/
|
|
43
45
|
export declare const generateValidatorFile: (schema: JSONSchema, typeName: string, options?: GenerateValidatorFileOptions) => string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition';
|
|
2
2
|
import { collectValidatorImports } from './collect-validator-imports.js';
|
|
3
|
-
import { generateValidatorFunction } from './generate-validator-function.js';
|
|
3
|
+
import { generateBooleanGuard, generateValidatorFunction } from './generate-validator-function.js';
|
|
4
4
|
/**
|
|
5
5
|
* Generates a complete TypeScript validator file from a JSON Schema.
|
|
6
6
|
*
|
|
@@ -8,7 +8,8 @@ import { generateValidatorFunction } from './generate-validator-function.js';
|
|
|
8
8
|
* - Imports for the ValidationResult/ValidationError types
|
|
9
9
|
* - Imports for any $ref types and their validator functions
|
|
10
10
|
* - The exported TypeScript type definition
|
|
11
|
-
* - The exported validator function
|
|
11
|
+
* - The exported validator function (`validateX`, rich `ValidationResult`)
|
|
12
|
+
* - The exported boolean type-guard (`isX`, a flat `input is X` predicate)
|
|
12
13
|
*
|
|
13
14
|
* @example
|
|
14
15
|
* ```typescript
|
|
@@ -21,6 +22,7 @@ import { generateValidatorFunction } from './generate-validator-function.js';
|
|
|
21
22
|
* // import type { ValidationResult, ValidationError } from './validation-result'
|
|
22
23
|
* // export type Info = { title: string }
|
|
23
24
|
* // export const validateInfo = (input: unknown, _path = ''): ValidationResult => { ... }
|
|
25
|
+
* // export const isInfo = (input: unknown): input is Info => { ... }
|
|
24
26
|
* ```
|
|
25
27
|
*/
|
|
26
28
|
export const generateValidatorFile = (schema, typeName, options) => {
|
|
@@ -32,6 +34,7 @@ export const generateValidatorFile = (schema, typeName, options) => {
|
|
|
32
34
|
});
|
|
33
35
|
const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix });
|
|
34
36
|
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix);
|
|
37
|
+
const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
|
|
35
38
|
let result = `import type { ValidationResult, ValidationError } from './validation-result'\n`;
|
|
36
39
|
// `const` checks on object/array values call the runtime `valuesEqual` helper.
|
|
37
40
|
// Only import it when the generated body actually uses it, so files without a
|
|
@@ -48,6 +51,6 @@ export const generateValidatorFile = (schema, typeName, options) => {
|
|
|
48
51
|
else {
|
|
49
52
|
result += '\n';
|
|
50
53
|
}
|
|
51
|
-
result += typeDefinition + '\n\n' + validatorFunction;
|
|
54
|
+
result += typeDefinition + '\n\n' + validatorFunction + '\n\n' + booleanGuard;
|
|
52
55
|
return result;
|
|
53
56
|
};
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Generates the exported boolean type-guard `isTypeName(input): input is TypeName`.
|
|
4
|
+
*
|
|
5
|
+
* Unlike `validateTypeName` (which returns rich `ValidationResult` errors), this
|
|
6
|
+
* is a single flat boolean predicate — no error array, no cold-path call — so V8
|
|
7
|
+
* inlines it like a hand-written `check`, matching the shape of TypeBox's
|
|
8
|
+
* compiled checker. It returns the *same verdict* as the validator. When the
|
|
9
|
+
* schema carries anything the flat form can't mirror exactly, it falls back to
|
|
10
|
+
* `validateTypeName(input) === true`, which is always correct.
|
|
11
|
+
*/
|
|
12
|
+
export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string, _suffix?: string) => string;
|
|
2
13
|
/**
|
|
3
14
|
* Generates a TypeScript validator function from a JSON Schema.
|
|
4
15
|
*
|
|
@@ -1,6 +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 { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
4
5
|
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
6
|
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
6
7
|
/**
|
|
@@ -405,7 +406,8 @@ const generatePropertyNameChecks = (nameSchema, suffix) => {
|
|
|
405
406
|
const guardPropConditions = (key, propSchema, objAcc) => {
|
|
406
407
|
if (!isSchemaObject(propSchema))
|
|
407
408
|
return null;
|
|
408
|
-
|
|
409
|
+
// Dotted access (`obj.number`) for identifier keys, bracket access otherwise.
|
|
410
|
+
const raw = safeAccessor(objAcc, key);
|
|
409
411
|
// Anything the slow path enforces past a typeof is cheaper to leave to the
|
|
410
412
|
// slow path than to mirror here, so bail and keep the guard sound.
|
|
411
413
|
if (hasRef(propSchema) ||
|
|
@@ -447,6 +449,34 @@ const guardPropConditions = (key, propSchema, objAcc) => {
|
|
|
447
449
|
return null;
|
|
448
450
|
}
|
|
449
451
|
};
|
|
452
|
+
/** A property key an array carries with a non-`undefined` value: `length`, or a
|
|
453
|
+
* canonical array index. A required prop on one of these can't be used to rule
|
|
454
|
+
* out arrays (an array's `length` is a number, an index can be anything). */
|
|
455
|
+
const ARRAY_INDEX_KEY = /^(0|[1-9]\d*)$/;
|
|
456
|
+
/** Schema types whose guard is a `typeof` check `typeof undefined` never passes. */
|
|
457
|
+
const TYPEOF_CHECKABLE_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object']);
|
|
458
|
+
/**
|
|
459
|
+
* Whether some required, typeof-guarded property proves the value can't be an
|
|
460
|
+
* array — letting the object shape-check drop its `!Array.isArray(...)` term. An
|
|
461
|
+
* array indexed by a normal key yields `undefined` (or an inherited method),
|
|
462
|
+
* which no `typeof === 'string' | 'number' | 'boolean' | 'object'` accepts, so
|
|
463
|
+
* that field check already rejects arrays. Keys an array does carry a real value
|
|
464
|
+
* for (`length`, numeric indices) are excluded, since those could slip through.
|
|
465
|
+
*/
|
|
466
|
+
const arrayRejectedByRequiredProp = (keys, required, properties) => {
|
|
467
|
+
for (const key of keys) {
|
|
468
|
+
if (!required.has(key) || key === 'length' || ARRAY_INDEX_KEY.test(key))
|
|
469
|
+
continue;
|
|
470
|
+
const propSchema = properties[key];
|
|
471
|
+
if (propSchema !== undefined &&
|
|
472
|
+
isSchemaObject(propSchema) &&
|
|
473
|
+
hasType(propSchema) &&
|
|
474
|
+
TYPEOF_CHECKABLE_TYPES.has(propSchema.type)) {
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
};
|
|
450
480
|
/**
|
|
451
481
|
* Builds the allocation-free boolean guard for an object schema as a list of
|
|
452
482
|
* `&&` conditions, or `null` when the schema can't be proven valid by a cheap
|
|
@@ -479,7 +509,10 @@ const guardObjectConditions = (schema, raw, objAcc) => {
|
|
|
479
509
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
480
510
|
const properties = hasProperties(schema) ? schema.properties : {};
|
|
481
511
|
const keys = Object.keys(properties);
|
|
482
|
-
|
|
512
|
+
// The object shape-check only needs `!Array.isArray` when no required field
|
|
513
|
+
// check would already reject an array (see `arrayRejectedByRequiredProp`).
|
|
514
|
+
const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? '' : ` && !Array.isArray(${raw})`;
|
|
515
|
+
const conditions = [`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`];
|
|
483
516
|
for (const key of keys) {
|
|
484
517
|
// An optional property would need an `=== undefined ||` branch and breaks
|
|
485
518
|
// the key-count trick, so the guard only covers all-required objects.
|
|
@@ -553,19 +586,18 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
553
586
|
const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
|
|
554
587
|
// A pure boolean guard for the happy path: when every property is present and
|
|
555
588
|
// well-typed (and, for strict objects, there are no extras) it returns true
|
|
556
|
-
// without allocating an `errors` array or
|
|
589
|
+
// without allocating an `errors` array or touching the slow path. It returns
|
|
557
590
|
// true only for provably valid input; anything it can't prove cheaply falls
|
|
558
|
-
// through to the error-collecting
|
|
559
|
-
//
|
|
560
|
-
//
|
|
591
|
+
// through to the error-collecting path, which produces the same verdict and
|
|
592
|
+
// full JSON-Pointer errors. Schemas with constraints the guard can't express
|
|
593
|
+
// produce no guard at all (`null`), leaving behaviour unchanged.
|
|
561
594
|
const guard = guardObjectConditions(schema, 'input', 'obj');
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
`${
|
|
595
|
+
// The cold, error-collecting body. When there's a guard this is a separate
|
|
596
|
+
// (unexported) function reached only on failure; the hot path never enters it
|
|
597
|
+
// unless input is actually invalid, so its size never costs the happy path.
|
|
598
|
+
const collectBody = (name, exported) => [
|
|
599
|
+
`${exported ? 'export ' : ''}const ${name} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
567
600
|
` const obj = input as Record<string, unknown>`,
|
|
568
|
-
...guardBlock,
|
|
569
601
|
` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
|
|
570
602
|
` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
|
|
571
603
|
` }`,
|
|
@@ -575,6 +607,244 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
575
607
|
` return errors.length > 0 ? { valid: false, errors } : true`,
|
|
576
608
|
`}`,
|
|
577
609
|
].join('\n');
|
|
610
|
+
// No guard: the exported validator is the error-collecting function itself.
|
|
611
|
+
if (!guard) {
|
|
612
|
+
return `${hoistedBlock}${collectBody(vName, true)}`;
|
|
613
|
+
}
|
|
614
|
+
// With a guard, keep the happy path inside the exported function — the guard
|
|
615
|
+
// is inlined as an early `return true`, so a valid input never pays an extra
|
|
616
|
+
// call — and move only the cold, error-collecting body into a separate
|
|
617
|
+
// (unexported) function. That keeps `validateX` itself tiny (guard + a single
|
|
618
|
+
// tail call) so V8 optimises it well, without the giant error body bloating
|
|
619
|
+
// the hot path. The exported `(input, _path?) => ValidationResult` contract
|
|
620
|
+
// is unchanged.
|
|
621
|
+
const collectName = `${vName}Errors`;
|
|
622
|
+
return [
|
|
623
|
+
`${hoistedBlock}${collectBody(collectName, false)}`,
|
|
624
|
+
``,
|
|
625
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
626
|
+
` const obj = input as Record<string, unknown>`,
|
|
627
|
+
` if (`,
|
|
628
|
+
guard.map((condition) => ` ${condition}`).join(' &&\n'),
|
|
629
|
+
` ) {`,
|
|
630
|
+
` return true`,
|
|
631
|
+
` }`,
|
|
632
|
+
` return ${collectName}(input, _path)`,
|
|
633
|
+
`}`,
|
|
634
|
+
].join('\n');
|
|
635
|
+
};
|
|
636
|
+
/**
|
|
637
|
+
* Derives the boolean type-guard name from a type name.
|
|
638
|
+
* e.g. "InfoObject" → "isInfoObject"
|
|
639
|
+
*/
|
|
640
|
+
const guardName = (typeName) => `is${typeName}`;
|
|
641
|
+
/**
|
|
642
|
+
* Positive type check for a value — the negation of {@link wrongTypeCondition}.
|
|
643
|
+
* Used by the boolean type-guard, which proves validity with `&&` conditions
|
|
644
|
+
* rather than collecting errors. Object is a shape-only check (matching the
|
|
645
|
+
* validator, which never recurses into array items or untyped object values).
|
|
646
|
+
*/
|
|
647
|
+
const rightTypeCondition = (accessor, type) => {
|
|
648
|
+
switch (type) {
|
|
649
|
+
case 'string':
|
|
650
|
+
return `typeof ${accessor} === 'string'`;
|
|
651
|
+
case 'number':
|
|
652
|
+
case 'integer':
|
|
653
|
+
return `typeof ${accessor} === 'number'`;
|
|
654
|
+
case 'boolean':
|
|
655
|
+
return `typeof ${accessor} === 'boolean'`;
|
|
656
|
+
case 'array':
|
|
657
|
+
return `Array.isArray(${accessor})`;
|
|
658
|
+
case 'object':
|
|
659
|
+
return `typeof ${accessor} === 'object' && ${accessor} !== null && !Array.isArray(${accessor})`;
|
|
660
|
+
default:
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
/**
|
|
665
|
+
* Builds a boolean expression that is TRUE iff `acc` satisfies `schema`, with the
|
|
666
|
+
* *exact same verdict* as the error-collecting validator — or `null` when the
|
|
667
|
+
* schema carries something the flat form can't faithfully mirror ($ref, unions,
|
|
668
|
+
* `const`, x-mjst, etc.), in which case the whole guard falls back to calling the
|
|
669
|
+
* validator. Used for a property value or an array item; `acc` is the expression
|
|
670
|
+
* yielding the value.
|
|
671
|
+
*/
|
|
672
|
+
const booleanLeafExpr = (schema, acc) => {
|
|
673
|
+
if (!isSchemaObject(schema))
|
|
674
|
+
return null;
|
|
675
|
+
// Anything whose verdict the flat form can't mirror exactly: defer to the
|
|
676
|
+
// validator (the caller turns a single `null` into a full fallback guard).
|
|
677
|
+
if (hasRef(schema) ||
|
|
678
|
+
hasConst(schema) ||
|
|
679
|
+
hasOneOf(schema) ||
|
|
680
|
+
'anyOf' in schema ||
|
|
681
|
+
'allOf' in schema ||
|
|
682
|
+
'not' in schema ||
|
|
683
|
+
getMjstInstanceOf(schema) !== undefined ||
|
|
684
|
+
getMjstPrimitive(schema) !== undefined) {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
// enum — same membership test the validator uses.
|
|
688
|
+
if (hasEnum(schema)) {
|
|
689
|
+
return `(${JSON.stringify(schema.enum)} as unknown[]).includes(${acc})`;
|
|
690
|
+
}
|
|
691
|
+
if (!hasType(schema))
|
|
692
|
+
return null;
|
|
693
|
+
const t = schema.type;
|
|
694
|
+
switch (t) {
|
|
695
|
+
// Each constraint is the exact negation of the validator's error condition
|
|
696
|
+
// (`!(len < min)`, not `len >= min`) so edge values — most importantly `NaN`,
|
|
697
|
+
// which the validator accepts for a constrained number since `NaN < min` is
|
|
698
|
+
// false — get the identical verdict.
|
|
699
|
+
case 'string': {
|
|
700
|
+
const parts = [`typeof ${acc} === 'string'`];
|
|
701
|
+
if (hasPattern(schema))
|
|
702
|
+
parts.push(`/${escapeRegexPattern(schema.pattern)}/.test(${acc})`);
|
|
703
|
+
if (hasMinLength(schema))
|
|
704
|
+
parts.push(`!(${acc}.length < ${schema.minLength})`);
|
|
705
|
+
if (hasMaxLength(schema))
|
|
706
|
+
parts.push(`!(${acc}.length > ${schema.maxLength})`);
|
|
707
|
+
return parts.join(' && ');
|
|
708
|
+
}
|
|
709
|
+
case 'number':
|
|
710
|
+
case 'integer': {
|
|
711
|
+
const parts = [`typeof ${acc} === 'number'`];
|
|
712
|
+
if (hasMinimum(schema))
|
|
713
|
+
parts.push(`!(${acc} ${hasStrictExclusiveMinimum(schema) ? '<=' : '<'} ${schema.minimum})`);
|
|
714
|
+
if (hasMaximum(schema))
|
|
715
|
+
parts.push(`!(${acc} ${hasStrictExclusiveMaximum(schema) ? '>=' : '>'} ${schema.maximum})`);
|
|
716
|
+
if (hasExclusiveMinimum(schema))
|
|
717
|
+
parts.push(`!(${acc} <= ${schema.exclusiveMinimum})`);
|
|
718
|
+
if (hasExclusiveMaximum(schema))
|
|
719
|
+
parts.push(`!(${acc} >= ${schema.exclusiveMaximum})`);
|
|
720
|
+
if (hasMultipleOf(schema))
|
|
721
|
+
parts.push(`${acc} % ${schema.multipleOf} === 0`);
|
|
722
|
+
return parts.join(' && ');
|
|
723
|
+
}
|
|
724
|
+
case 'boolean':
|
|
725
|
+
return `typeof ${acc} === 'boolean'`;
|
|
726
|
+
case 'object': {
|
|
727
|
+
const parts = booleanObjectParts(schema, acc, `(${acc} as Record<string, unknown>)`);
|
|
728
|
+
return parts === null ? null : parts.join(' && ');
|
|
729
|
+
}
|
|
730
|
+
case 'array':
|
|
731
|
+
return booleanArrayExpr(schema, acc);
|
|
732
|
+
default:
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
/**
|
|
737
|
+
* Boolean expression for an array value. Mirrors the validator, which checks the
|
|
738
|
+
* array shape and — for typed items — only each item's *type* (objects are shape-
|
|
739
|
+
* checked, not recursed into); it never enforces `minItems`/`maxItems` or item
|
|
740
|
+
* constraints. Returns `null` for `$ref` items (those defer to the validator).
|
|
741
|
+
*/
|
|
742
|
+
const booleanArrayExpr = (schema, acc) => {
|
|
743
|
+
const base = `Array.isArray(${acc})`;
|
|
744
|
+
if (!hasItems(schema))
|
|
745
|
+
return base;
|
|
746
|
+
const items = schema.items;
|
|
747
|
+
if (!isSchemaObject(items))
|
|
748
|
+
return base;
|
|
749
|
+
if (hasRef(items))
|
|
750
|
+
return null;
|
|
751
|
+
if (!hasType(items))
|
|
752
|
+
return base;
|
|
753
|
+
const itemCheck = rightTypeCondition('_it', items.type);
|
|
754
|
+
if (itemCheck === null)
|
|
755
|
+
return base;
|
|
756
|
+
return `${base} && (${acc} as unknown[]).every((_it) => ${itemCheck})`;
|
|
757
|
+
};
|
|
758
|
+
/**
|
|
759
|
+
* Builds the `&&` conditions proving an object value is valid (same verdict as
|
|
760
|
+
* the error-collecting validator), or `null` when any property or object-level
|
|
761
|
+
* keyword can't be mirrored flat. `raw` yields the value (for the shape check);
|
|
762
|
+
* `objAcc` is the same value narrowed to a record (for member access).
|
|
763
|
+
*/
|
|
764
|
+
const booleanObjectParts = (schema, raw, objAcc) => {
|
|
765
|
+
if (!isObjectSchema(schema))
|
|
766
|
+
return null;
|
|
767
|
+
// These need per-key loops or cross-references the flat form can't express.
|
|
768
|
+
if (hasDependentRequired(schema) || hasPropertyNames(schema))
|
|
769
|
+
return null;
|
|
770
|
+
if (isSchemaObject(schema) && 'patternProperties' in schema)
|
|
771
|
+
return null;
|
|
772
|
+
let strict = false;
|
|
773
|
+
if (hasAdditionalProperties(schema)) {
|
|
774
|
+
// Only `additionalProperties: false` is expressible; a schema needs per-key
|
|
775
|
+
// validation, so defer to the validator.
|
|
776
|
+
if (schema.additionalProperties === false)
|
|
777
|
+
strict = true;
|
|
778
|
+
else
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
782
|
+
const properties = hasProperties(schema) ? schema.properties : {};
|
|
783
|
+
const keys = Object.keys(properties);
|
|
784
|
+
// Drop the `!Array.isArray` term when a required, typeof-guarded property
|
|
785
|
+
// already rejects arrays (an array's normal key is `undefined`, which no
|
|
786
|
+
// `typeof` accepts) — the same sound optimisation the validator's hot guard
|
|
787
|
+
// uses. Kept when no such property exists.
|
|
788
|
+
const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? '' : ` && !Array.isArray(${raw})`;
|
|
789
|
+
const parts = [`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`];
|
|
790
|
+
for (const key of keys) {
|
|
791
|
+
const propSchema = properties[key];
|
|
792
|
+
if (propSchema === undefined || !isSchemaObject(propSchema))
|
|
793
|
+
return null;
|
|
794
|
+
const member = safeAccessor(objAcc, key);
|
|
795
|
+
const expr = booleanLeafExpr(propSchema, member);
|
|
796
|
+
if (expr === null)
|
|
797
|
+
return null;
|
|
798
|
+
parts.push(required.has(key) ? expr : `(${member} === undefined || (${expr}))`);
|
|
799
|
+
}
|
|
800
|
+
if (strict) {
|
|
801
|
+
// `additionalProperties: false`: with every property required, an exact key
|
|
802
|
+
// count proves no extras (the typeof checks above already proved presence);
|
|
803
|
+
// otherwise sweep the keys against the declared set.
|
|
804
|
+
if (keys.length === 0) {
|
|
805
|
+
parts.push(`Object.keys(${objAcc}).length === 0`);
|
|
806
|
+
}
|
|
807
|
+
else if (keys.every((key) => required.has(key))) {
|
|
808
|
+
parts.push(`Object.keys(${objAcc}).length === ${keys.length}`);
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
const known = keys.map((key) => `_k === ${JSON.stringify(key)}`).join(' || ');
|
|
812
|
+
parts.push(`Object.keys(${objAcc}).every((_k) => ${known})`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return parts;
|
|
816
|
+
};
|
|
817
|
+
/**
|
|
818
|
+
* Generates the exported boolean type-guard `isTypeName(input): input is TypeName`.
|
|
819
|
+
*
|
|
820
|
+
* Unlike `validateTypeName` (which returns rich `ValidationResult` errors), this
|
|
821
|
+
* is a single flat boolean predicate — no error array, no cold-path call — so V8
|
|
822
|
+
* inlines it like a hand-written `check`, matching the shape of TypeBox's
|
|
823
|
+
* compiled checker. It returns the *same verdict* as the validator. When the
|
|
824
|
+
* schema carries anything the flat form can't mirror exactly, it falls back to
|
|
825
|
+
* `validateTypeName(input) === true`, which is always correct.
|
|
826
|
+
*/
|
|
827
|
+
export const generateBooleanGuard = (schema, typeName, _suffix = '') => {
|
|
828
|
+
const name = guardName(typeName);
|
|
829
|
+
const fallback = `export const ${name} = (input: unknown): input is ${typeName} => ${validatorName(typeName)}(input) === true`;
|
|
830
|
+
if (isObjectSchema(schema)) {
|
|
831
|
+
const parts = booleanObjectParts(schema, 'input', 'obj');
|
|
832
|
+
if (parts === null)
|
|
833
|
+
return fallback;
|
|
834
|
+
return [
|
|
835
|
+
`export const ${name} = (input: unknown): input is ${typeName} => {`,
|
|
836
|
+
` const obj = input as Record<string, unknown>`,
|
|
837
|
+
` return (`,
|
|
838
|
+
parts.map((part) => ` ${part}`).join(' &&\n'),
|
|
839
|
+
` )`,
|
|
840
|
+
`}`,
|
|
841
|
+
].join('\n');
|
|
842
|
+
}
|
|
843
|
+
// Non-object roots (scalar, enum, array) can often be expressed inline too.
|
|
844
|
+
const expr = booleanLeafExpr(schema, 'input');
|
|
845
|
+
if (expr === null)
|
|
846
|
+
return fallback;
|
|
847
|
+
return `export const ${name} = (input: unknown): input is ${typeName} => ${expr}`;
|
|
578
848
|
};
|
|
579
849
|
/**
|
|
580
850
|
* Generates a validator function for a non-object schema (primitive, array, enum, $ref).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-validators",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Generate TypeScript validation functions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
|
|
34
34
|
"types:check": "tsgo -p . --noEmit",
|
|
35
35
|
"test": "NODE_ENV=production vitest run --root ../.. generate-validators",
|
|
36
|
-
"bench": "bun
|
|
36
|
+
"bench": "bun --conditions development ./bench/run.ts"
|
|
37
37
|
},
|
|
38
38
|
"imports": {
|
|
39
39
|
"#generators/*": "./src/generators/*.ts"
|
|
@@ -49,10 +49,12 @@
|
|
|
49
49
|
"@amritk/helpers": "0.10.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
+
"@ryoppippi/unplugin-typia": "^2.6.5",
|
|
52
53
|
"@scalar/openapi-parser": "^0.26.1",
|
|
53
54
|
"@sinclair/typebox": "^0.34.49",
|
|
54
55
|
"ajv": "^8.17.1",
|
|
55
56
|
"ajv-formats": "^3.0.1",
|
|
57
|
+
"typia": "^12.1.1",
|
|
56
58
|
"zod": "^4.4.3"
|
|
57
59
|
}
|
|
58
60
|
}
|