@amritk/generate-parsers 0.9.0 โ†’ 0.11.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
@@ -134,6 +134,16 @@ Returns: `Promise<GeneratedFile[]>`.
134
134
  <tr>
135
135
  <td colspan="5">Generate parsers that throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. When a schema sets additionalProperties: false, undeclared keys throw too; otherwise they are still allowed.</td>
136
136
  </tr>
137
+ <tr>
138
+ <td>๐Ÿงน <code>stripUnknown</code></td>
139
+ <td>โ€”</td>
140
+ <td><code>boolean</code></td>
141
+ <td align="center">โ€”</td>
142
+ <td align="center"><code>false</code></td>
143
+ </tr>
144
+ <tr>
145
+ <td colspan="5">Build each parser's result from the schema's declared properties only, silently dropping undeclared input keys at every nesting level (zod's .strip()). Extras are never a validation error, so this composes with strict (which still throws on wrong types and missing required properties) and yields to additionalProperties: false, which rejects rather than strips in strict mode.</td>
146
+ </tr>
137
147
  </tbody>
138
148
  </table>
139
149
  <!-- config-table-end -->
@@ -38,6 +38,10 @@ export type GeneratedFile = {
38
38
  * directory while the helper sources are emitted once at the output root.
39
39
  * @param readonly - When true, every property, array, and record in the generated type
40
40
  * definitions is emitted as `readonly`.
41
+ * @param stripUnknown - When true, the generated parsers build their result from declared
42
+ * properties only, silently dropping undeclared input keys at every nesting level (zod's
43
+ * `.strip()`) without treating extras as a validation error. Composes with `strict` and
44
+ * yields to `additionalProperties: false` (which rejects rather than strips in strict mode).
41
45
  * @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
42
46
  * (e.g. `'Object'` โ†’ `ContactObject`). Defaults to `''` (no suffix). The root type name
43
47
  * is used verbatim and is not affected by this suffix.
@@ -73,4 +77,4 @@ export type GeneratedFile = {
73
77
  * });
74
78
  * ```
75
79
  */
76
- export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
80
+ export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
@@ -43,6 +43,10 @@ const readHelperSource = async (helper) => {
43
43
  * directory while the helper sources are emitted once at the output root.
44
44
  * @param readonly - When true, every property, array, and record in the generated type
45
45
  * definitions is emitted as `readonly`.
46
+ * @param stripUnknown - When true, the generated parsers build their result from declared
47
+ * properties only, silently dropping undeclared input keys at every nesting level (zod's
48
+ * `.strip()`) without treating extras as a validation error. Composes with `strict` and
49
+ * yields to `additionalProperties: false` (which rejects rather than strips in strict mode).
46
50
  * @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
47
51
  * (e.g. `'Object'` โ†’ `ContactObject`). Defaults to `''` (no suffix). The root type name
48
52
  * is used verbatim and is not affected by this suffix.
@@ -78,7 +82,7 @@ const readHelperSource = async (helper) => {
78
82
  * });
79
83
  * ```
80
84
  */
81
- export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, typeSuffix = '') => {
85
+ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '') => {
82
86
  const files = [];
83
87
  const usedHelpers = new Set();
84
88
  walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
@@ -99,6 +103,7 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
99
103
  ...(node.ref !== undefined ? { selfRef: node.ref } : {}),
100
104
  ...(logWarnings !== undefined ? { logWarnings } : {}),
101
105
  ...(strict !== undefined ? { strict } : {}),
106
+ ...(stripUnknown ? { stripUnknown } : {}),
102
107
  });
103
108
  files.push({ filename: `${node.filename}.ts`, content: result.content });
104
109
  for (const helper of result.usedHelpers)
@@ -30,6 +30,15 @@ type GenerateFileOptions = {
30
30
  * of coercing invalid input to default values.
31
31
  */
32
32
  readonly strict?: boolean;
33
+ /**
34
+ * When true, the generated parser builds its result from declared properties
35
+ * only, silently dropping undeclared input keys at every nesting level (zod's
36
+ * `.strip()`), without treating extras as a validation error. Composes with
37
+ * `strict` (still throws on wrong types / missing required properties) and
38
+ * yields to `additionalProperties: false` (which rejects rather than strips in
39
+ * strict mode).
40
+ */
41
+ readonly stripUnknown?: boolean;
33
42
  /**
34
43
  * Controls how runtime helpers are referenced.
35
44
  * - `'package'` (default): emit imports from `@amritk/helpers/...`.
@@ -51,13 +51,15 @@ export const generateFile = (schema, typeName, options) => {
51
51
  }
52
52
  return { content: result + typeDefinition, usedHelpers: new Set() };
53
53
  }
54
+ const stripUnknown = options?.stripUnknown ?? false;
54
55
  const parserFunction = generateParserFunction(schema, typeName, {
55
56
  useRefImports: true,
56
57
  typeSuffix,
57
58
  ...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
58
59
  ...(options?.strict !== undefined ? { strict: options.strict } : {}),
60
+ ...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
59
61
  });
60
- const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix);
62
+ const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown);
61
63
  const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
62
64
  const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix);
63
65
  const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix }), ...helpers.imports];
@@ -21,6 +21,16 @@ type GenerateParserOptions = {
21
21
  * undeclared keys throw too; otherwise they are still allowed.
22
22
  */
23
23
  readonly strict?: boolean;
24
+ /**
25
+ * When true, the generated parser builds its result from the schema's declared
26
+ * properties only, silently dropping any undeclared input key at every nesting
27
+ * level (zod's `.strip()`). Unlike `additionalProperties: false`, extras are
28
+ * never a validation error โ€” this composes with `strict`: `strict + stripUnknown`
29
+ * still throws on wrong types and missing required properties but strips extras
30
+ * instead of throwing on them. When the schema *also* sets
31
+ * `additionalProperties: false`, rejecting wins over stripping in strict mode.
32
+ */
33
+ readonly stripUnknown?: boolean;
24
34
  /**
25
35
  * Suffix appended to every type/parser name derived from a `$ref`. Must match
26
36
  * the suffix used when generating the referenced files. Defaults to `''`.
@@ -55,5 +65,5 @@ export declare const shapeValidatorFunctionName: (typeName: string) => string;
55
65
  * in additionalProperties). The stub is safe: parents calling it will fall
56
66
  * through to the slow path, matching the pre-deep-fast-path behavior.
57
67
  */
58
- export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean) => string;
68
+ export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean) => string;
59
69
  export {};
@@ -530,7 +530,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
530
530
  * - Fast path that returns input directly when all properties are already valid
531
531
  * - Slow path with optimized expressions
532
532
  */
533
- const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true) => {
533
+ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false) => {
534
534
  const functionName = generateParserName(typeName);
535
535
  const exportPrefix = exported ? 'export ' : '';
536
536
  if (!hasProperties(schema)) {
@@ -549,38 +549,32 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
549
549
  for (const [key, subName] of subTypeNames) {
550
550
  const propSchema = schema.properties[key];
551
551
  preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
552
- preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false));
553
- preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false));
552
+ preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
553
+ preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown));
554
554
  }
555
555
  // additionalProperties: false โ€” the per-key "is this undeclared" test inlines
556
556
  // `!==` comparisons for a short key list and hoists a Set only for a long one
557
557
  // (see unknownKeyCheck). The predicate form is shared by the fast path and the
558
558
  // shape validator; strict mode throws with the offending key.
559
+ // `additionalProperties: false` strips *and rejects* extras; `stripUnknown`
560
+ // only strips them. Both build the result from declared properties alone, so
561
+ // they share the same machinery (`stripKeys`): drop the `...input` spread and
562
+ // gate the `{ ...input }` fast path on the known-keys predicate. Only the real
563
+ // `strictKeys` makes an extra a hard error (the throw block further down).
559
564
  const strictKeys = hasStrictKeys(schema);
565
+ const stripKeys = strictKeys || stripUnknown;
560
566
  const strictKeyCheck = unknownKeyCheck(properties.map(([key]) => key), `_knownKeys${typeName}`);
561
- if (strictKeys) {
567
+ if (stripKeys) {
562
568
  for (const declaration of strictKeyCheck.declarations) {
563
569
  preamble.push(`${declaration};`);
564
570
  }
565
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};`);
566
572
  }
567
573
  const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
568
- const lines = [];
569
- lines.push(`${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => {`);
570
- if (strict) {
571
- for (const assertionLine of generateObjectStrictAssertion(schema, typeName)) {
572
- lines.push(assertionLine);
573
- }
574
- if (strictKeys) {
575
- lines.push(` for (const _k in input) {`);
576
- lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
577
- lines.push(` }`);
578
- }
579
- }
580
- else {
581
- lines.push(` if (!isObject(input)) return ${fallbackObject};`);
582
- }
583
- // 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.
584
578
  const propInfo = [];
585
579
  // Disable fast path when allOf $ref parsers need to be spread โ€” the fast path
586
580
  // returns input as-is and would skip those coercions.
@@ -594,160 +588,259 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
594
588
  const isRequired = isPropertyRequired(key, schema);
595
589
  const varName = toVarName(key);
596
590
  propInfo.push({ key, varName, isRequired, propSchema });
597
- // Check if this property can participate in fast path
598
- if (canFastPath) {
599
- // Records of refs require iterating every value to check shape โ€” too
600
- // expensive to inline into the parser's fast path. Disable.
601
- if (shouldUseRecordRefImport(propSchema, useRefImports)) {
602
- 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);
603
657
  }
604
658
  else {
605
- const subName = subTypeNames.get(key);
606
- // Inline nested objects fast-path through their private shape
607
- // predicate, the same way $ref properties use the imported one.
608
- const check = subName
609
- ? `${shapeValidatorName(subName)}(${varName})`
610
- : generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
611
- if (check === null) {
612
- canFastPath = false;
613
- }
614
- else {
615
- if (isRequired) {
616
- fastPathChecks.push(check);
617
- }
618
- else {
619
- fastPathChecks.push(`(${varName} === undefined || ${check})`);
620
- }
621
- }
659
+ check = null;
660
+ }
661
+ if (check === null) {
662
+ canShallowGuard = false;
663
+ break;
622
664
  }
665
+ shallowChecks.push(isRequired ? check : `(${varName} === undefined || ${check})`);
623
666
  }
624
667
  }
625
- // The fast path returns `{ ...input }`, so with additionalProperties: false
626
- // it must only fire when no undeclared keys are present. Strict mode already
627
- // threw on them above.
628
- if (strictKeys && !strict) {
629
- fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
630
- }
631
- // 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 = [];
632
672
  for (const { key, varName, propSchema } of propInfo) {
633
673
  if (shouldCacheVariable(propSchema, canFastPath, useRefImports)) {
634
- lines.push(` const ${varName} = ${safeAccessor('input', key)};`);
674
+ varDeclLines.push(` const ${varName} = ${safeAccessor('input', key)};`);
635
675
  }
636
676
  }
637
- // 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 = [];
638
679
  if (logWarnings && propInfo.length > 0) {
639
680
  const warnKeyCheck = unknownKeyCheck(propInfo.map(({ key }) => key), '_knownKeys');
640
681
  for (const declaration of warnKeyCheck.declarations) {
641
- lines.push(` ${declaration};`);
682
+ warnLines.push(` ${declaration};`);
642
683
  }
643
- lines.push(` for (const _k in input) {`);
644
- lines.push(` if (${warnKeyCheck.isUnknown('_k')}) {`);
645
- lines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`);
646
- lines.push(` }`);
647
- lines.push(` }`);
648
- }
649
- // Generate fast-path check if possible
650
- if (canFastPath && fastPathChecks.length > 0) {
651
- let fastPathExpr = fastPathChecks[0];
652
- for (let i = 1; i < fastPathChecks.length; i++) {
653
- 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,');
654
700
  }
655
- lines.push(` if (${fastPathExpr}) return { ...input } as ${typeName};`);
656
- }
657
- // Generate slow-path object construction. The input spread preserves
658
- // undeclared keys, so it is dropped when additionalProperties: false โ€”
659
- // building from the declared properties alone strips them from the result.
660
- const objectLines = [];
661
- if (!strictKeys) {
662
- objectLines.push(' ...input,');
663
- }
664
- // Spread allOf $ref parsers so their field coercions are applied before
665
- // the explicit property validations below.
666
- if (useRefImports && isSchemaObject(schema) && hasAllOf(schema)) {
667
- for (const entry of schema.allOf) {
668
- if (isSchemaObject(entry) && hasRef(entry)) {
669
- const parserName = generateParserName(refToName(entry.$ref, suffix));
670
- 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
+ }
671
709
  }
672
710
  }
673
- }
674
- for (const { key, varName, isRequired, propSchema } of propInfo) {
675
- const shouldCache = shouldCacheVariable(propSchema, canFastPath, useRefImports);
676
- const accessor = shouldCache ? varName : safeAccessor('input', key);
677
- // Handle inline nested objects via their private sub-parser, mirroring
678
- // how $ref properties delegate to the imported parser.
679
- const subName = subTypeNames.get(key);
680
- if (subName) {
681
- const subParserName = generateParserName(subName);
682
- if (isRequired) {
683
- 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;
684
726
  }
685
- else {
686
- 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;
687
738
  }
688
- continue;
689
- }
690
- // Handle direct $ref properties via imported parsers
691
- if (shouldUseRefImport(propSchema, useRefImports)) {
692
- const ref = propSchema.$ref;
693
- const parserName = generateParserName(refToName(ref, suffix));
694
- if (isRequired) {
695
- 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;
696
751
  }
697
- else {
698
- 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;
699
764
  }
700
- continue;
701
- }
702
- // Handle array properties with $ref items
703
- if (shouldUseArrayRefImport(propSchema, useRefImports)) {
704
- const items = propSchema.items;
705
- const ref = items.$ref;
706
- const parserName = generateParserName(refToName(ref, suffix));
707
- if (isRequired) {
708
- 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;
709
777
  }
710
778
  else {
711
- 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);
712
784
  }
713
- continue;
714
- }
715
- // Handle object properties with additionalProperties $ref
716
- if (shouldUseRecordRefImport(propSchema, useRefImports)) {
717
- const additionalProps = propSchema.additionalProperties;
718
- const ref = additionalProps.$ref;
719
- const parserName = generateParserName(refToName(ref, suffix));
720
785
  if (isRequired) {
721
- objectLines.push(` ${safeKey(key)}: validateRecord(${accessor}, ${parserName}),`);
786
+ objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
722
787
  }
723
788
  else {
724
- objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateRecord(${accessor}, ${parserName}) }),`);
789
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${valueExpr} }),`);
725
790
  }
726
- continue;
727
791
  }
728
- // Handle non-schema-object properties
729
- if (!isSchemaObject(propSchema)) {
730
- if (isRequired) {
731
- 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
+ const lines = [];
800
+ lines.push(`${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => {`);
801
+ if (strict) {
802
+ // Guard first: a non-object throws straight away; a clean, well-typed input
803
+ // then short-circuits past the per-property assertions, which only run to
804
+ // pinpoint the failure when the guard rejects the input.
805
+ lines.push(` if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`);
806
+ lines.push(...varDeclLines);
807
+ lines.push(...warnLines);
808
+ // The first assertion line is the `isObject` check, already done above.
809
+ const assertionLines = generateObjectStrictAssertion(schema, typeName).slice(1);
810
+ if (shallowGuard) {
811
+ // stripUnknown: a well-typed input skips the assertions and goes straight
812
+ // to the strip build (which removes extras and recurses into sub-parsers).
813
+ lines.push(` if (!(${shallowGuard})) {`);
814
+ for (const assertionLine of assertionLines) {
815
+ lines.push(` ${assertionLine}`);
732
816
  }
733
- continue;
734
- }
735
- // Generate validation expression
736
- const defaultValue = getDefaultValue(propSchema);
737
- // For optional properties in the slow path, we know the value is not undefined
738
- // because we're inside the `...(accessor !== undefined && { ... })` check
739
- const knownNotUndefined = !isRequired;
740
- const valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined);
741
- if (isRequired) {
742
- objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
817
+ lines.push(` }`);
818
+ emitReturn(lines, buildObjectLines(true));
743
819
  }
744
820
  else {
745
- objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${valueExpr} }),`);
821
+ if (deepGuard) {
822
+ lines.push(` if (${deepGuard}) return { ...input } as ${typeName};`);
823
+ }
824
+ for (const assertionLine of assertionLines) {
825
+ lines.push(assertionLine);
826
+ }
827
+ if (strictKeys) {
828
+ lines.push(` for (const _k in input) {`);
829
+ lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
830
+ lines.push(` }`);
831
+ }
832
+ emitReturn(lines, buildObjectLines(true));
833
+ }
834
+ }
835
+ else {
836
+ lines.push(` if (!isObject(input)) return ${fallbackObject};`);
837
+ lines.push(...varDeclLines);
838
+ lines.push(...warnLines);
839
+ if (deepGuard) {
840
+ lines.push(` if (${deepGuard}) return { ...input } as ${typeName};`);
746
841
  }
842
+ emitReturn(lines, buildObjectLines(false));
747
843
  }
748
- lines.push(` return {`);
749
- lines.push(objectLines.join('\n'));
750
- lines.push(` } as unknown as ${typeName};`);
751
844
  lines.push(`}`);
752
845
  const fn = lines.join('\n');
753
846
  if (preamble.length === 0)
@@ -811,7 +904,7 @@ ${loopLines.join('\n')}
811
904
  * Generates a parser for schemas that have both properties AND patternProperties.
812
905
  * Parses the known properties first, then iterates remaining keys to match patterns.
813
906
  */
814
- const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict) => {
907
+ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false) => {
815
908
  const functionName = generateParserName(typeName);
816
909
  const entries = generatePropertyEntries(schema, useRefImports, suffix);
817
910
  // Build the known property lines for the initial object
@@ -823,7 +916,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
823
916
  });
824
917
  // Find the first pattern with a $ref for parser delegation
825
918
  if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
826
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
919
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
827
920
  }
828
921
  const patternProps = schema.patternProperties;
829
922
  const patterns = Object.entries(patternProps);
@@ -837,7 +930,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
837
930
  return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
838
931
  }
839
932
  if (!refPattern || !useRefImports) {
840
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
933
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
841
934
  }
842
935
  const [pattern, patternSchema] = refPattern;
843
936
  const ref = patternSchema.$ref;
@@ -1016,7 +1109,7 @@ ${notObjectBranch}
1016
1109
  * This always generates parser calls regardless of useRefImports setting,
1017
1110
  * because conditional logic requires delegating to the appropriate parser.
1018
1111
  */
1019
- const generateConditionalParser = (schema, typeName, suffix) => {
1112
+ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false) => {
1020
1113
  const functionName = generateParserName(typeName);
1021
1114
  // Extract the condition and branches
1022
1115
  const ifSchema = schema.if;
@@ -1031,7 +1124,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
1031
1124
  // regular object parser from the merged property set.
1032
1125
  const mergedSchema = getConditionalObjectSchema(schema);
1033
1126
  if (mergedSchema) {
1034
- return generateObjectParser(mergedSchema, typeName, false, suffix);
1127
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
1035
1128
  }
1036
1129
  return generateEmptyObjectParser(typeName);
1037
1130
  }
@@ -1045,7 +1138,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
1045
1138
  // Condition is not a $ref check โ€” flatten into an object parser
1046
1139
  const mergedSchema = getConditionalObjectSchema(schema);
1047
1140
  if (mergedSchema) {
1048
- return generateObjectParser(mergedSchema, typeName, false, suffix);
1141
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
1049
1142
  }
1050
1143
  return generateEmptyObjectParser(typeName);
1051
1144
  }
@@ -1140,6 +1233,7 @@ const selectParserStrategy = (schema, typeName, options) => {
1140
1233
  const useRefImports = options?.useRefImports ?? false;
1141
1234
  const logWarnings = options?.logWarnings ?? false;
1142
1235
  const strict = options?.strict ?? false;
1236
+ const stripUnknown = options?.stripUnknown ?? false;
1143
1237
  const suffix = options?.typeSuffix ?? '';
1144
1238
  // Special case for the self-referential JSON Schema meta-schema type (e.g.
1145
1239
  // `Schema` / `SchemaObject`) - it can be any JSON Schema.
@@ -1156,24 +1250,24 @@ const selectParserStrategy = (schema, typeName, options) => {
1156
1250
  // This generates a parser that handles known properties and also iterates
1157
1251
  // pattern-matched keys (e.g. responses with "default" + "200", "4XX").
1158
1252
  if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
1159
- return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
1253
+ return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown);
1160
1254
  }
1161
1255
  // Handle schemas that have explicit properties โ€” generate a full object parser.
1162
1256
  // This intentionally runs before if/then checks so that schemas with both
1163
1257
  // properties AND conditional keywords use all declared properties rather than
1164
1258
  // only the if/then fragment.
1165
1259
  if (hasProperties(schema)) {
1166
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
1260
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1167
1261
  }
1168
1262
  // Handle conditional schemas (if/then/else) for schemas without explicit properties.
1169
1263
  if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
1170
- return generateConditionalParser(schema, typeName, suffix);
1264
+ return generateConditionalParser(schema, typeName, suffix, stripUnknown);
1171
1265
  }
1172
1266
  // Handle conditional schemas that only define if/then object fragments.
1173
1267
  // We flatten the fragments into a regular object parser.
1174
1268
  const conditionalObjectSchema = getConditionalObjectSchema(schema);
1175
1269
  if (conditionalObjectSchema) {
1176
- return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict);
1270
+ return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1177
1271
  }
1178
1272
  // Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
1179
1273
  if (!isObjectLikeSchema) {
@@ -1230,7 +1324,7 @@ export const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeN
1230
1324
  * in additionalProperties). The stub is safe: parents calling it will fall
1231
1325
  * through to the slow path, matching the pre-deep-fast-path behavior.
1232
1326
  */
1233
- export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true) => {
1327
+ export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false) => {
1234
1328
  const fnName = shapeValidatorName(typeName);
1235
1329
  const exportPrefix = exported ? 'export ' : '';
1236
1330
  const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
@@ -1253,9 +1347,12 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
1253
1347
  // Same deterministic naming as the parser's sub-parser generation, so the
1254
1348
  // referenced private shape predicates exist in the same file.
1255
1349
  const subTypeNames = collectInlineObjectProperties(schema, typeName);
1256
- // With additionalProperties: false the shape only matches when every key is
1257
- // declared, so the parser's fast path will not smuggle extras through.
1258
- const strictKeysGuard = hasStrictKeys(schema) ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;` : '';
1350
+ // When the parser strips extras (additionalProperties: false or stripUnknown),
1351
+ // an input carrying an undeclared key is *not* fast-path eligible โ€” the parser
1352
+ // would strip it rather than return `{ ...input }` โ€” so the shape only matches
1353
+ // when every key is declared. (The `_hasOnlyKnownKeys` predicate this calls is
1354
+ // emitted by the parser under the same `stripKeys` condition.)
1355
+ const strictKeysGuard = hasStrictKeys(schema) || stripUnknown ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;` : '';
1259
1356
  const checks = [];
1260
1357
  for (const [key, propSchema] of properties) {
1261
1358
  // Records of refs require iterating every value โ€” too expensive for the fast path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -32,7 +32,8 @@
32
32
  "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
33
33
  "types:check": "tsgo -p . --noEmit",
34
34
  "test": "NODE_ENV=production vitest run --root ../.. generate-parsers",
35
- "generate-readme": "bun run ./scripts/generate-readme.ts"
35
+ "generate-readme": "bun run ./scripts/generate-readme.ts",
36
+ "bench": "bun --conditions development ./bench/run.ts"
36
37
  },
37
38
  "imports": {
38
39
  "#generators/*": "./src/generators/*.ts",
@@ -49,5 +50,9 @@
49
50
  "json-schema-typed": "^8.0.1",
50
51
  "@amritk/helpers": "0.10.0",
51
52
  "@amritk/generate-markdown": "0.2.4"
53
+ },
54
+ "devDependencies": {
55
+ "@sinclair/typebox": "^0.34.49",
56
+ "zod": "^4.4.3"
52
57
  }
53
58
  }