@amritk/generate-parsers 0.10.0 → 0.11.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.
@@ -571,22 +571,10 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
571
571
  preamble.push(`const _hasOnlyKnownKeys${typeName} = (input: Record<string, unknown>): boolean => {\n for (const _k in input) if (${strictKeyCheck.isUnknown('_k')}) return false;\n return true;\n};`);
572
572
  }
573
573
  const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
574
- const lines = [];
575
- lines.push(`${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => {`);
576
- if (strict) {
577
- for (const assertionLine of generateObjectStrictAssertion(schema, typeName)) {
578
- lines.push(assertionLine);
579
- }
580
- if (strictKeys) {
581
- lines.push(` for (const _k in input) {`);
582
- lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
583
- lines.push(` }`);
584
- }
585
- }
586
- else {
587
- lines.push(` if (!isObject(input)) return ${fallbackObject};`);
588
- }
589
- // First pass: determine if we can generate a fast path
574
+ // First pass: gather per-property info and decide whether a fast path is
575
+ // possible. A fast path returns the input (or a direct strip-build) for
576
+ // already-valid input without re-running per-property validation, so it may
577
+ // only fire when every property can be cheaply proven in-shape.
590
578
  const propInfo = [];
591
579
  // Disable fast path when allOf $ref parsers need to be spread — the fast path
592
580
  // returns input as-is and would skip those coercions.
@@ -600,162 +588,293 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
600
588
  const isRequired = isPropertyRequired(key, schema);
601
589
  const varName = toVarName(key);
602
590
  propInfo.push({ key, varName, isRequired, propSchema });
603
- // Check if this property can participate in fast path
604
- if (canFastPath) {
605
- // Records of refs require iterating every value to check shape — too
606
- // expensive to inline into the parser's fast path. Disable.
607
- if (shouldUseRecordRefImport(propSchema, useRefImports)) {
608
- canFastPath = false;
591
+ if (!canFastPath)
592
+ continue;
593
+ // Records of refs require iterating every value to check shape — too
594
+ // expensive to inline into the parser's fast path. Disable.
595
+ if (shouldUseRecordRefImport(propSchema, useRefImports)) {
596
+ canFastPath = false;
597
+ continue;
598
+ }
599
+ const subName = subTypeNames.get(key);
600
+ // Inline nested objects fast-path through their private shape predicate, the
601
+ // same way $ref properties use the imported one.
602
+ const check = subName
603
+ ? `${shapeValidatorName(subName)}(${varName})`
604
+ : generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
605
+ if (check === null) {
606
+ canFastPath = false;
607
+ }
608
+ else if (isRequired) {
609
+ fastPathChecks.push(check);
610
+ }
611
+ else {
612
+ fastPathChecks.push(`(${varName} === undefined || ${check})`);
613
+ }
614
+ }
615
+ // The `{ ...input }` fast path preserves extras, so when stripping it may only
616
+ // fire on inputs that carry no undeclared key. For strict + additionalProperties:
617
+ // false the cold path rejects extras instead; folding the known-keys term into
618
+ // the guard lets the guard run *before* that rejection, so a valid clean input
619
+ // never pays for the per-property assertions.
620
+ if (stripKeys) {
621
+ fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
622
+ }
623
+ // The deep guard proves the whole shape (so `{ ...input }` can be returned),
624
+ // using the nested shape predicates and the known-keys term above.
625
+ const deepGuard = canFastPath && fastPathChecks.length > 0 ? fastPathChecks.join(' && ') : null;
626
+ // The shallow guard powers the strict strip-build fast path (stripUnknown
627
+ // without additionalProperties: false): it proves every scalar is well-typed
628
+ // and every nested object is an object, but neither walks nested shapes deeply
629
+ // nor requires the absence of extras. That lets it fire on the common
630
+ // stripUnknown input — which carries extras the build removes, and nested
631
+ // extras each sub-parser removes — while the cold path's per-property
632
+ // assertions still produce the precise error on a genuine mismatch.
633
+ let canShallowGuard = strict && stripUnknown && !strictKeys;
634
+ const shallowChecks = [];
635
+ if (canShallowGuard) {
636
+ for (const { key, varName, isRequired, propSchema } of propInfo) {
637
+ const subName = subTypeNames.get(key);
638
+ let check;
639
+ if (subName) {
640
+ // Nested inline object: a shallow `isObject` is enough — the sub-parser
641
+ // validates and strips it.
642
+ check = `isObject(${varName})`;
643
+ }
644
+ else if (isSchemaObject(propSchema) &&
645
+ !hasEnum(propSchema) &&
646
+ !hasRef(propSchema) &&
647
+ !hasOneOf(propSchema) &&
648
+ !hasAnyOf(propSchema) &&
649
+ !hasAllOf(propSchema) &&
650
+ !('not' in propSchema) &&
651
+ !shouldUseRefImport(propSchema, useRefImports) &&
652
+ !shouldUseArrayRefImport(propSchema, useRefImports) &&
653
+ !shouldUseRecordRefImport(propSchema, useRefImports)) {
654
+ // Plain scalar / array / object: its own type check is already shallow
655
+ // (arrays are not walked, objects only `isObject`-checked).
656
+ check = generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
609
657
  }
610
658
  else {
611
- const subName = subTypeNames.get(key);
612
- // Inline nested objects fast-path through their private shape
613
- // predicate, the same way $ref properties use the imported one.
614
- const check = subName
615
- ? `${shapeValidatorName(subName)}(${varName})`
616
- : generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
617
- if (check === null) {
618
- canFastPath = false;
619
- }
620
- else {
621
- if (isRequired) {
622
- fastPathChecks.push(check);
623
- }
624
- else {
625
- fastPathChecks.push(`(${varName} === undefined || ${check})`);
626
- }
627
- }
659
+ check = null;
628
660
  }
661
+ if (check === null) {
662
+ canShallowGuard = false;
663
+ break;
664
+ }
665
+ shallowChecks.push(isRequired ? check : `(${varName} === undefined || ${check})`);
629
666
  }
630
667
  }
631
- // The fast path returns `{ ...input }`, which preserves extras — so when we're
632
- // stripping it must only fire when no undeclared key is present. The exception
633
- // is strict + additionalProperties: false, where the throw block above already
634
- // rejected any extra, so the fast path is safe to take unconditionally.
635
- if (stripKeys && !(strict && strictKeys)) {
636
- fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
637
- }
638
- // Second pass: generate variable declarations only for properties that need them
668
+ const shallowGuard = canShallowGuard && shallowChecks.length > 0 ? shallowChecks.join(' && ') : null;
669
+ // Variable declarations for properties that need them (the build and guards
670
+ // read each cached value once instead of re-accessing `input`).
671
+ const varDeclLines = [];
639
672
  for (const { key, varName, propSchema } of propInfo) {
640
673
  if (shouldCacheVariable(propSchema, canFastPath, useRefImports)) {
641
- lines.push(` const ${varName} = ${safeAccessor('input', key)};`);
674
+ varDeclLines.push(` const ${varName} = ${safeAccessor('input', key)};`);
642
675
  }
643
676
  }
644
- // Emit a warning for any input key not declared in the schema's properties
677
+ // Emit a warning for any input key not declared in the schema's properties.
678
+ const warnLines = [];
645
679
  if (logWarnings && propInfo.length > 0) {
646
680
  const warnKeyCheck = unknownKeyCheck(propInfo.map(({ key }) => key), '_knownKeys');
647
681
  for (const declaration of warnKeyCheck.declarations) {
648
- lines.push(` ${declaration};`);
682
+ warnLines.push(` ${declaration};`);
649
683
  }
650
- lines.push(` for (const _k in input) {`);
651
- lines.push(` if (${warnKeyCheck.isUnknown('_k')}) {`);
652
- lines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`);
653
- lines.push(` }`);
654
- lines.push(` }`);
655
- }
656
- // Generate fast-path check if possible
657
- if (canFastPath && fastPathChecks.length > 0) {
658
- let fastPathExpr = fastPathChecks[0];
659
- for (let i = 1; i < fastPathChecks.length; i++) {
660
- fastPathExpr += ' && ' + fastPathChecks[i];
684
+ warnLines.push(` for (const _k in input) {`);
685
+ warnLines.push(` if (${warnKeyCheck.isUnknown('_k')}) {`);
686
+ warnLines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`);
687
+ warnLines.push(` }`);
688
+ warnLines.push(` }`);
689
+ }
690
+ // Builds the result object literal. `directAssign` (strict mode) assigns each
691
+ // field straight from its cached read — the guard or the per-property
692
+ // assertions have already proven the type, so no coercion ternary is needed.
693
+ // Coerce mode keeps the type-checking-and-coercing expression. Refs, arrays of
694
+ // refs, records of refs and inline nested objects always delegate to their
695
+ // parser in both modes; the input spread is dropped whenever stripping.
696
+ const buildObjectLines = (directAssign) => {
697
+ const objectLines = [];
698
+ if (!stripKeys) {
699
+ objectLines.push(' ...input,');
661
700
  }
662
- lines.push(` if (${fastPathExpr}) return { ...input } as ${typeName};`);
663
- }
664
- // Generate slow-path object construction. The input spread preserves
665
- // undeclared keys, so it is dropped whenever we're stripping (either
666
- // additionalProperties: false or stripUnknown) — building from the declared
667
- // properties alone strips the extras from the result.
668
- const objectLines = [];
669
- if (!stripKeys) {
670
- objectLines.push(' ...input,');
671
- }
672
- // Spread allOf $ref parsers so their field coercions are applied before
673
- // the explicit property validations below.
674
- if (useRefImports && isSchemaObject(schema) && hasAllOf(schema)) {
675
- for (const entry of schema.allOf) {
676
- if (isSchemaObject(entry) && hasRef(entry)) {
677
- const parserName = generateParserName(refToName(entry.$ref, suffix));
678
- objectLines.push(` ...(${parserName}(input) as Record<string, unknown>),`);
701
+ // Spread allOf $ref parsers so their field coercions are applied before
702
+ // the explicit property validations below.
703
+ if (useRefImports && isSchemaObject(schema) && hasAllOf(schema)) {
704
+ for (const entry of schema.allOf) {
705
+ if (isSchemaObject(entry) && hasRef(entry)) {
706
+ const parserName = generateParserName(refToName(entry.$ref, suffix));
707
+ objectLines.push(` ...(${parserName}(input) as Record<string, unknown>),`);
708
+ }
679
709
  }
680
710
  }
681
- }
682
- for (const { key, varName, isRequired, propSchema } of propInfo) {
683
- const shouldCache = shouldCacheVariable(propSchema, canFastPath, useRefImports);
684
- const accessor = shouldCache ? varName : safeAccessor('input', key);
685
- // Handle inline nested objects via their private sub-parser, mirroring
686
- // how $ref properties delegate to the imported parser.
687
- const subName = subTypeNames.get(key);
688
- if (subName) {
689
- const subParserName = generateParserName(subName);
690
- if (isRequired) {
691
- objectLines.push(` ${safeKey(key)}: ${subParserName}(${accessor}),`);
711
+ for (const { key, varName, isRequired, propSchema } of propInfo) {
712
+ const shouldCache = shouldCacheVariable(propSchema, canFastPath, useRefImports);
713
+ const accessor = shouldCache ? varName : safeAccessor('input', key);
714
+ // Handle inline nested objects via their private sub-parser, mirroring
715
+ // how $ref properties delegate to the imported parser.
716
+ const subName = subTypeNames.get(key);
717
+ if (subName) {
718
+ const subParserName = generateParserName(subName);
719
+ if (isRequired) {
720
+ objectLines.push(` ${safeKey(key)}: ${subParserName}(${accessor}),`);
721
+ }
722
+ else {
723
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${subParserName}(${accessor}) }),`);
724
+ }
725
+ continue;
692
726
  }
693
- else {
694
- objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${subParserName}(${accessor}) }),`);
727
+ // Handle direct $ref properties via imported parsers
728
+ if (shouldUseRefImport(propSchema, useRefImports)) {
729
+ const ref = propSchema.$ref;
730
+ const parserName = generateParserName(refToName(ref, suffix));
731
+ if (isRequired) {
732
+ objectLines.push(` ${safeKey(key)}: ${parserName}(${accessor}),`);
733
+ }
734
+ else {
735
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${parserName}(${accessor}) }),`);
736
+ }
737
+ continue;
695
738
  }
696
- continue;
697
- }
698
- // Handle direct $ref properties via imported parsers
699
- if (shouldUseRefImport(propSchema, useRefImports)) {
700
- const ref = propSchema.$ref;
701
- const parserName = generateParserName(refToName(ref, suffix));
702
- if (isRequired) {
703
- objectLines.push(` ${safeKey(key)}: ${parserName}(${accessor}),`);
739
+ // Handle array properties with $ref items
740
+ if (shouldUseArrayRefImport(propSchema, useRefImports)) {
741
+ const items = propSchema.items;
742
+ const ref = items.$ref;
743
+ const parserName = generateParserName(refToName(ref, suffix));
744
+ if (isRequired) {
745
+ objectLines.push(` ${safeKey(key)}: validateArray(${accessor}, ${parserName}),`);
746
+ }
747
+ else {
748
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateArray(${accessor}, ${parserName}) }),`);
749
+ }
750
+ continue;
704
751
  }
705
- else {
706
- objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${parserName}(${accessor}) }),`);
752
+ // Handle object properties with additionalProperties $ref
753
+ if (shouldUseRecordRefImport(propSchema, useRefImports)) {
754
+ const additionalProps = propSchema.additionalProperties;
755
+ const ref = additionalProps.$ref;
756
+ const parserName = generateParserName(refToName(ref, suffix));
757
+ if (isRequired) {
758
+ objectLines.push(` ${safeKey(key)}: validateRecord(${accessor}, ${parserName}),`);
759
+ }
760
+ else {
761
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateRecord(${accessor}, ${parserName}) }),`);
762
+ }
763
+ continue;
707
764
  }
708
- continue;
709
- }
710
- // Handle array properties with $ref items
711
- if (shouldUseArrayRefImport(propSchema, useRefImports)) {
712
- const items = propSchema.items;
713
- const ref = items.$ref;
714
- const parserName = generateParserName(refToName(ref, suffix));
715
- if (isRequired) {
716
- objectLines.push(` ${safeKey(key)}: validateArray(${accessor}, ${parserName}),`);
765
+ // Handle non-schema-object properties
766
+ if (!isSchemaObject(propSchema)) {
767
+ if (isRequired) {
768
+ objectLines.push(` ${safeKey(key)}: undefined,`);
769
+ }
770
+ continue;
771
+ }
772
+ // Strict mode has already proven the value's type (by guard or assertion),
773
+ // so the field is assigned straight from the cached read with no coercion.
774
+ let valueExpr;
775
+ if (directAssign) {
776
+ valueExpr = accessor;
717
777
  }
718
778
  else {
719
- objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateArray(${accessor}, ${parserName}) }),`);
779
+ const defaultValue = getDefaultValue(propSchema);
780
+ // For optional properties we know the value is not undefined because
781
+ // we're inside the `...(accessor !== undefined && { ... })` check.
782
+ const knownNotUndefined = !isRequired;
783
+ valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined);
720
784
  }
721
- continue;
722
- }
723
- // Handle object properties with additionalProperties $ref
724
- if (shouldUseRecordRefImport(propSchema, useRefImports)) {
725
- const additionalProps = propSchema.additionalProperties;
726
- const ref = additionalProps.$ref;
727
- const parserName = generateParserName(refToName(ref, suffix));
728
785
  if (isRequired) {
729
- objectLines.push(` ${safeKey(key)}: validateRecord(${accessor}, ${parserName}),`);
786
+ objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
730
787
  }
731
788
  else {
732
- objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateRecord(${accessor}, ${parserName}) }),`);
789
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${valueExpr} }),`);
733
790
  }
734
- continue;
735
791
  }
736
- // Handle non-schema-object properties
737
- if (!isSchemaObject(propSchema)) {
738
- if (isRequired) {
739
- objectLines.push(` ${safeKey(key)}: undefined,`);
792
+ return objectLines;
793
+ };
794
+ const emitReturn = (lines, objectLines) => {
795
+ lines.push(` return {`);
796
+ lines.push(objectLines.join('\n'));
797
+ lines.push(` } as unknown as ${typeName};`);
798
+ };
799
+ // The deep-guard fast path. `{ ...input }` is the only correct shape when the
800
+ // guard let undeclared keys through (no known-keys term) — they must survive.
801
+ // But when `stripKeys` is set the guard *also* proved `_hasOnlyKnownKeys`, so
802
+ // the input's keys are exactly the declared properties: an explicit literal of
803
+ // those keys is then equivalent to the spread, and faster — a fixed-shape
804
+ // literal beats a generic spread, yields a stable hidden class, and produces
805
+ // the same declared key order as the slow path (the spread used input order).
806
+ // The literal shares each value by reference, exactly like the spread did, so
807
+ // it never re-parses an already-validated nested object. allOf merges in
808
+ // properties this `propInfo` list doesn't carry, so it keeps the spread.
809
+ const emitDeepGuardReturn = (lines) => {
810
+ const canLiteral = stripKeys && !(isSchemaObject(schema) && hasAllOf(schema));
811
+ if (!canLiteral) {
812
+ lines.push(` if (${deepGuard}) return { ...input } as ${typeName};`);
813
+ return;
814
+ }
815
+ const fields = [];
816
+ for (const { key, varName, isRequired, propSchema } of propInfo) {
817
+ if (!isSchemaObject(propSchema)) {
818
+ if (isRequired)
819
+ fields.push(` ${safeKey(key)}: undefined,`);
820
+ continue;
740
821
  }
741
- continue;
822
+ const accessor = shouldCacheVariable(propSchema, canFastPath, useRefImports)
823
+ ? varName
824
+ : safeAccessor('input', key);
825
+ fields.push(isRequired
826
+ ? ` ${safeKey(key)}: ${accessor},`
827
+ : ` ...(${accessor} !== undefined && { ${safeKey(key)}: ${accessor} }),`);
742
828
  }
743
- // Generate validation expression
744
- const defaultValue = getDefaultValue(propSchema);
745
- // For optional properties in the slow path, we know the value is not undefined
746
- // because we're inside the `...(accessor !== undefined && { ... })` check
747
- const knownNotUndefined = !isRequired;
748
- const valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined);
749
- if (isRequired) {
750
- objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
829
+ lines.push(` if (${deepGuard}) return {`);
830
+ lines.push(fields.join('\n'));
831
+ lines.push(` } as ${typeName};`);
832
+ };
833
+ const lines = [];
834
+ lines.push(`${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => {`);
835
+ if (strict) {
836
+ // Guard first: a non-object throws straight away; a clean, well-typed input
837
+ // then short-circuits past the per-property assertions, which only run to
838
+ // pinpoint the failure when the guard rejects the input.
839
+ lines.push(` if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`);
840
+ lines.push(...varDeclLines);
841
+ lines.push(...warnLines);
842
+ // The first assertion line is the `isObject` check, already done above.
843
+ const assertionLines = generateObjectStrictAssertion(schema, typeName).slice(1);
844
+ if (shallowGuard) {
845
+ // stripUnknown: a well-typed input skips the assertions and goes straight
846
+ // to the strip build (which removes extras and recurses into sub-parsers).
847
+ lines.push(` if (!(${shallowGuard})) {`);
848
+ for (const assertionLine of assertionLines) {
849
+ lines.push(` ${assertionLine}`);
850
+ }
851
+ lines.push(` }`);
852
+ emitReturn(lines, buildObjectLines(true));
751
853
  }
752
854
  else {
753
- objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${valueExpr} }),`);
855
+ if (deepGuard) {
856
+ emitDeepGuardReturn(lines);
857
+ }
858
+ for (const assertionLine of assertionLines) {
859
+ lines.push(assertionLine);
860
+ }
861
+ if (strictKeys) {
862
+ lines.push(` for (const _k in input) {`);
863
+ lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
864
+ lines.push(` }`);
865
+ }
866
+ emitReturn(lines, buildObjectLines(true));
867
+ }
868
+ }
869
+ else {
870
+ lines.push(` if (!isObject(input)) return ${fallbackObject};`);
871
+ lines.push(...varDeclLines);
872
+ lines.push(...warnLines);
873
+ if (deepGuard) {
874
+ emitDeepGuardReturn(lines);
754
875
  }
876
+ emitReturn(lines, buildObjectLines(false));
755
877
  }
756
- lines.push(` return {`);
757
- lines.push(objectLines.join('\n'));
758
- lines.push(` } as unknown as ${typeName};`);
759
878
  lines.push(`}`);
760
879
  const fn = lines.join('\n');
761
880
  if (preamble.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",