@amritk/generate-validators 0.9.0 → 0.10.1
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.
|
@@ -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
|
*
|
|
@@ -580,7 +580,12 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
580
580
|
if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
|
|
581
581
|
propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
|
|
582
582
|
}
|
|
583
|
-
|
|
583
|
+
// Lazily allocate the errors array so a valid input never builds one — the same
|
|
584
|
+
// allocation-free happy path the runtime interpreter uses. Each emitted
|
|
585
|
+
// `errors.push(...)` becomes a create-on-first-use push; nothing is allocated
|
|
586
|
+
// until the first actual error, so the common valid case stays alloc-free even
|
|
587
|
+
// when the schema is too rich for the boolean guard.
|
|
588
|
+
const body = (propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '').replaceAll('errors.push(', '(errors ??= []).push(');
|
|
584
589
|
// Hoisted statements (e.g. known-keys Sets) come first so every call of the
|
|
585
590
|
// validator reuses them instead of rebuilding them.
|
|
586
591
|
const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
|
|
@@ -602,9 +607,9 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
602
607
|
` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
|
|
603
608
|
` }`,
|
|
604
609
|
``,
|
|
605
|
-
`
|
|
610
|
+
` let errors: ValidationError[] | undefined`,
|
|
606
611
|
body,
|
|
607
|
-
` return errors
|
|
612
|
+
` return errors !== undefined ? { valid: false, errors } : true`,
|
|
608
613
|
`}`,
|
|
609
614
|
].join('\n');
|
|
610
615
|
// No guard: the exported validator is the error-collecting function itself.
|
|
@@ -633,6 +638,242 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
633
638
|
`}`,
|
|
634
639
|
].join('\n');
|
|
635
640
|
};
|
|
641
|
+
/**
|
|
642
|
+
* Derives the boolean type-guard name from a type name.
|
|
643
|
+
* e.g. "InfoObject" → "isInfoObject"
|
|
644
|
+
*/
|
|
645
|
+
const guardName = (typeName) => `is${typeName}`;
|
|
646
|
+
/**
|
|
647
|
+
* Positive type check for a value — the negation of {@link wrongTypeCondition}.
|
|
648
|
+
* Used by the boolean type-guard, which proves validity with `&&` conditions
|
|
649
|
+
* rather than collecting errors. Object is a shape-only check (matching the
|
|
650
|
+
* validator, which never recurses into array items or untyped object values).
|
|
651
|
+
*/
|
|
652
|
+
const rightTypeCondition = (accessor, type) => {
|
|
653
|
+
switch (type) {
|
|
654
|
+
case 'string':
|
|
655
|
+
return `typeof ${accessor} === 'string'`;
|
|
656
|
+
case 'number':
|
|
657
|
+
case 'integer':
|
|
658
|
+
return `typeof ${accessor} === 'number'`;
|
|
659
|
+
case 'boolean':
|
|
660
|
+
return `typeof ${accessor} === 'boolean'`;
|
|
661
|
+
case 'array':
|
|
662
|
+
return `Array.isArray(${accessor})`;
|
|
663
|
+
case 'object':
|
|
664
|
+
return `typeof ${accessor} === 'object' && ${accessor} !== null && !Array.isArray(${accessor})`;
|
|
665
|
+
default:
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* Builds the membership test for an `enum`, matching the slow path's
|
|
671
|
+
* `[...].includes(value)` verdict exactly. For the common all-primitive case it
|
|
672
|
+
* emits a parenthesized `a === x || a === y` chain — no per-call array
|
|
673
|
+
* allocation and no linear scan, so it stays on the allocation-free hot path —
|
|
674
|
+
* and falls back to `.includes` when a member is an object/array (reference
|
|
675
|
+
* equality) or `NaN` (where `includes`'s SameValueZero differs from `===`).
|
|
676
|
+
*/
|
|
677
|
+
const enumMembershipExpr = (values, acc) => {
|
|
678
|
+
const allPrimitive = values.length > 0 &&
|
|
679
|
+
values.every((v) => (v === null || typeof v !== 'object') && typeof v !== 'function') &&
|
|
680
|
+
!values.some((v) => typeof v === 'number' && Number.isNaN(v));
|
|
681
|
+
if (allPrimitive) {
|
|
682
|
+
return `(${values.map((v) => `${acc} === ${JSON.stringify(v)}`).join(' || ')})`;
|
|
683
|
+
}
|
|
684
|
+
return `(${JSON.stringify(values)} as unknown[]).includes(${acc})`;
|
|
685
|
+
};
|
|
686
|
+
/**
|
|
687
|
+
* Builds a boolean expression that is TRUE iff `acc` satisfies `schema`, with the
|
|
688
|
+
* *exact same verdict* as the error-collecting validator — or `null` when the
|
|
689
|
+
* schema carries something the flat form can't faithfully mirror ($ref, unions,
|
|
690
|
+
* `const`, x-mjst, etc.), in which case the whole guard falls back to calling the
|
|
691
|
+
* validator. Used for a property value or an array item; `acc` is the expression
|
|
692
|
+
* yielding the value.
|
|
693
|
+
*/
|
|
694
|
+
const booleanLeafExpr = (schema, acc) => {
|
|
695
|
+
if (!isSchemaObject(schema))
|
|
696
|
+
return null;
|
|
697
|
+
// Anything whose verdict the flat form can't mirror exactly: defer to the
|
|
698
|
+
// validator (the caller turns a single `null` into a full fallback guard).
|
|
699
|
+
if (hasRef(schema) ||
|
|
700
|
+
hasConst(schema) ||
|
|
701
|
+
hasOneOf(schema) ||
|
|
702
|
+
'anyOf' in schema ||
|
|
703
|
+
'allOf' in schema ||
|
|
704
|
+
'not' in schema ||
|
|
705
|
+
getMjstInstanceOf(schema) !== undefined ||
|
|
706
|
+
getMjstPrimitive(schema) !== undefined) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
// enum — same membership test the validator uses.
|
|
710
|
+
if (hasEnum(schema)) {
|
|
711
|
+
return enumMembershipExpr(schema.enum, acc);
|
|
712
|
+
}
|
|
713
|
+
if (!hasType(schema))
|
|
714
|
+
return null;
|
|
715
|
+
const t = schema.type;
|
|
716
|
+
switch (t) {
|
|
717
|
+
// Each constraint is the exact negation of the validator's error condition
|
|
718
|
+
// (`!(len < min)`, not `len >= min`) so edge values — most importantly `NaN`,
|
|
719
|
+
// which the validator accepts for a constrained number since `NaN < min` is
|
|
720
|
+
// false — get the identical verdict.
|
|
721
|
+
case 'string': {
|
|
722
|
+
const parts = [`typeof ${acc} === 'string'`];
|
|
723
|
+
if (hasPattern(schema))
|
|
724
|
+
parts.push(`/${escapeRegexPattern(schema.pattern)}/.test(${acc})`);
|
|
725
|
+
if (hasMinLength(schema))
|
|
726
|
+
parts.push(`!(${acc}.length < ${schema.minLength})`);
|
|
727
|
+
if (hasMaxLength(schema))
|
|
728
|
+
parts.push(`!(${acc}.length > ${schema.maxLength})`);
|
|
729
|
+
return parts.join(' && ');
|
|
730
|
+
}
|
|
731
|
+
case 'number':
|
|
732
|
+
case 'integer': {
|
|
733
|
+
const parts = [`typeof ${acc} === 'number'`];
|
|
734
|
+
if (hasMinimum(schema))
|
|
735
|
+
parts.push(`!(${acc} ${hasStrictExclusiveMinimum(schema) ? '<=' : '<'} ${schema.minimum})`);
|
|
736
|
+
if (hasMaximum(schema))
|
|
737
|
+
parts.push(`!(${acc} ${hasStrictExclusiveMaximum(schema) ? '>=' : '>'} ${schema.maximum})`);
|
|
738
|
+
if (hasExclusiveMinimum(schema))
|
|
739
|
+
parts.push(`!(${acc} <= ${schema.exclusiveMinimum})`);
|
|
740
|
+
if (hasExclusiveMaximum(schema))
|
|
741
|
+
parts.push(`!(${acc} >= ${schema.exclusiveMaximum})`);
|
|
742
|
+
if (hasMultipleOf(schema))
|
|
743
|
+
parts.push(`${acc} % ${schema.multipleOf} === 0`);
|
|
744
|
+
return parts.join(' && ');
|
|
745
|
+
}
|
|
746
|
+
case 'boolean':
|
|
747
|
+
return `typeof ${acc} === 'boolean'`;
|
|
748
|
+
case 'object': {
|
|
749
|
+
const parts = booleanObjectParts(schema, acc, `(${acc} as Record<string, unknown>)`);
|
|
750
|
+
return parts === null ? null : parts.join(' && ');
|
|
751
|
+
}
|
|
752
|
+
case 'array':
|
|
753
|
+
return booleanArrayExpr(schema, acc);
|
|
754
|
+
default:
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
/**
|
|
759
|
+
* Boolean expression for an array value. Mirrors the validator, which checks the
|
|
760
|
+
* array shape and — for typed items — only each item's *type* (objects are shape-
|
|
761
|
+
* checked, not recursed into); it never enforces `minItems`/`maxItems` or item
|
|
762
|
+
* constraints. Returns `null` for `$ref` items (those defer to the validator).
|
|
763
|
+
*
|
|
764
|
+
* Item iteration goes through `Array.from` rather than `Array.prototype.every`
|
|
765
|
+
* because `every` *skips holes* in a sparse array (`[, 'x']`), whereas the
|
|
766
|
+
* validator's index-based `for` loop reads a hole as `undefined` and rejects it.
|
|
767
|
+
* Materialising the array first makes the guard's verdict match the slow path's
|
|
768
|
+
* on sparse input — the guard must never accept what the slow path would reject.
|
|
769
|
+
*/
|
|
770
|
+
const booleanArrayExpr = (schema, acc) => {
|
|
771
|
+
const base = `Array.isArray(${acc})`;
|
|
772
|
+
if (!hasItems(schema))
|
|
773
|
+
return base;
|
|
774
|
+
const items = schema.items;
|
|
775
|
+
if (!isSchemaObject(items))
|
|
776
|
+
return base;
|
|
777
|
+
if (hasRef(items))
|
|
778
|
+
return null;
|
|
779
|
+
if (!hasType(items))
|
|
780
|
+
return base;
|
|
781
|
+
const itemCheck = rightTypeCondition('_it', items.type);
|
|
782
|
+
if (itemCheck === null)
|
|
783
|
+
return base;
|
|
784
|
+
return `${base} && Array.from(${acc} as unknown[]).every((_it) => ${itemCheck})`;
|
|
785
|
+
};
|
|
786
|
+
/**
|
|
787
|
+
* Builds the `&&` conditions proving an object value is valid (same verdict as
|
|
788
|
+
* the error-collecting validator), or `null` when any property or object-level
|
|
789
|
+
* keyword can't be mirrored flat. `raw` yields the value (for the shape check);
|
|
790
|
+
* `objAcc` is the same value narrowed to a record (for member access).
|
|
791
|
+
*/
|
|
792
|
+
const booleanObjectParts = (schema, raw, objAcc) => {
|
|
793
|
+
if (!isObjectSchema(schema))
|
|
794
|
+
return null;
|
|
795
|
+
// These need per-key loops or cross-references the flat form can't express.
|
|
796
|
+
if (hasDependentRequired(schema) || hasPropertyNames(schema))
|
|
797
|
+
return null;
|
|
798
|
+
if (isSchemaObject(schema) && 'patternProperties' in schema)
|
|
799
|
+
return null;
|
|
800
|
+
let strict = false;
|
|
801
|
+
if (hasAdditionalProperties(schema)) {
|
|
802
|
+
// Only `additionalProperties: false` is expressible; a schema needs per-key
|
|
803
|
+
// validation, so defer to the validator.
|
|
804
|
+
if (schema.additionalProperties === false)
|
|
805
|
+
strict = true;
|
|
806
|
+
else
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
810
|
+
const properties = hasProperties(schema) ? schema.properties : {};
|
|
811
|
+
const keys = Object.keys(properties);
|
|
812
|
+
// Drop the `!Array.isArray` term when a required, typeof-guarded property
|
|
813
|
+
// already rejects arrays (an array's normal key is `undefined`, which no
|
|
814
|
+
// `typeof` accepts) — the same sound optimisation the validator's hot guard
|
|
815
|
+
// uses. Kept when no such property exists.
|
|
816
|
+
const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? '' : ` && !Array.isArray(${raw})`;
|
|
817
|
+
const parts = [`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`];
|
|
818
|
+
for (const key of keys) {
|
|
819
|
+
const propSchema = properties[key];
|
|
820
|
+
if (propSchema === undefined || !isSchemaObject(propSchema))
|
|
821
|
+
return null;
|
|
822
|
+
const member = safeAccessor(objAcc, key);
|
|
823
|
+
const expr = booleanLeafExpr(propSchema, member);
|
|
824
|
+
if (expr === null)
|
|
825
|
+
return null;
|
|
826
|
+
parts.push(required.has(key) ? expr : `(${member} === undefined || (${expr}))`);
|
|
827
|
+
}
|
|
828
|
+
if (strict) {
|
|
829
|
+
// `additionalProperties: false`: with every property required, an exact key
|
|
830
|
+
// count proves no extras (the typeof checks above already proved presence);
|
|
831
|
+
// otherwise sweep the keys against the declared set.
|
|
832
|
+
if (keys.length === 0) {
|
|
833
|
+
parts.push(`Object.keys(${objAcc}).length === 0`);
|
|
834
|
+
}
|
|
835
|
+
else if (keys.every((key) => required.has(key))) {
|
|
836
|
+
parts.push(`Object.keys(${objAcc}).length === ${keys.length}`);
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
const known = keys.map((key) => `_k === ${JSON.stringify(key)}`).join(' || ');
|
|
840
|
+
parts.push(`Object.keys(${objAcc}).every((_k) => ${known})`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return parts;
|
|
844
|
+
};
|
|
845
|
+
/**
|
|
846
|
+
* Generates the exported boolean type-guard `isTypeName(input): input is TypeName`.
|
|
847
|
+
*
|
|
848
|
+
* Unlike `validateTypeName` (which returns rich `ValidationResult` errors), this
|
|
849
|
+
* is a single flat boolean predicate — no error array, no cold-path call — so V8
|
|
850
|
+
* inlines it like a hand-written `check`, matching the shape of TypeBox's
|
|
851
|
+
* compiled checker. It returns the *same verdict* as the validator. When the
|
|
852
|
+
* schema carries anything the flat form can't mirror exactly, it falls back to
|
|
853
|
+
* `validateTypeName(input) === true`, which is always correct.
|
|
854
|
+
*/
|
|
855
|
+
export const generateBooleanGuard = (schema, typeName, _suffix = '') => {
|
|
856
|
+
const name = guardName(typeName);
|
|
857
|
+
const fallback = `export const ${name} = (input: unknown): input is ${typeName} => ${validatorName(typeName)}(input) === true`;
|
|
858
|
+
if (isObjectSchema(schema)) {
|
|
859
|
+
const parts = booleanObjectParts(schema, 'input', 'obj');
|
|
860
|
+
if (parts === null)
|
|
861
|
+
return fallback;
|
|
862
|
+
return [
|
|
863
|
+
`export const ${name} = (input: unknown): input is ${typeName} => {`,
|
|
864
|
+
` const obj = input as Record<string, unknown>`,
|
|
865
|
+
` return (`,
|
|
866
|
+
parts.map((part) => ` ${part}`).join(' &&\n'),
|
|
867
|
+
` )`,
|
|
868
|
+
`}`,
|
|
869
|
+
].join('\n');
|
|
870
|
+
}
|
|
871
|
+
// Non-object roots (scalar, enum, array) can often be expressed inline too.
|
|
872
|
+
const expr = booleanLeafExpr(schema, 'input');
|
|
873
|
+
if (expr === null)
|
|
874
|
+
return fallback;
|
|
875
|
+
return `export const ${name} = (input: unknown): input is ${typeName} => ${expr}`;
|
|
876
|
+
};
|
|
636
877
|
/**
|
|
637
878
|
* Generates a validator function for a non-object schema (primitive, array, enum, $ref).
|
|
638
879
|
*/
|
|
@@ -765,9 +1006,9 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
765
1006
|
` if (${wrongType}) {`,
|
|
766
1007
|
` return { valid: false, errors: [{ message: 'must be ${typLabel}', path: _path }] }`,
|
|
767
1008
|
` }`,
|
|
768
|
-
`
|
|
769
|
-
constraintLines.join('\n'),
|
|
770
|
-
` return errors
|
|
1009
|
+
` let errors: ValidationError[] | undefined`,
|
|
1010
|
+
constraintLines.join('\n').replaceAll('errors.push(', '(errors ??= []).push('),
|
|
1011
|
+
` return errors !== undefined ? { valid: false, errors } : true`,
|
|
771
1012
|
`}`,
|
|
772
1013
|
].join('\n');
|
|
773
1014
|
}
|