@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
- const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '';
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
- ` const errors: ValidationError[] = []`,
610
+ ` let errors: ValidationError[] | undefined`,
606
611
  body,
607
- ` return errors.length > 0 ? { valid: false, errors } : true`,
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
- ` const errors: ValidationError[] = []`,
769
- constraintLines.join('\n'),
770
- ` return errors.length > 0 ? { valid: false, errors } : true`,
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",