@amritk/generate-parsers 0.8.0 โ†’ 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 {};
@@ -2,6 +2,7 @@ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { refToName } from '@amritk/helpers/ref-to-name';
3
3
  import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
4
4
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDefault, hasEnum, hasExamples, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
+ import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
5
6
  import { getDefaultValue } from '../helpers/get-default-value.js';
6
7
  import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
7
8
  import { generateValidationExpression } from './generate-validation-expression.js';
@@ -529,7 +530,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
529
530
  * - Fast path that returns input directly when all properties are already valid
530
531
  * - Slow path with optimized expressions
531
532
  */
532
- const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true) => {
533
+ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false) => {
533
534
  const functionName = generateParserName(typeName);
534
535
  const exportPrefix = exported ? 'export ' : '';
535
536
  if (!hasProperties(schema)) {
@@ -548,16 +549,26 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
548
549
  for (const [key, subName] of subTypeNames) {
549
550
  const propSchema = schema.properties[key];
550
551
  preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
551
- preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false));
552
- preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false));
553
- }
554
- // additionalProperties: false โ€” hoist the declared-keys Set so the per-call
555
- // sweep is one Set lookup per key. The predicate form is shared by the fast
556
- // path and the shape validator; strict mode throws with the offending key.
552
+ preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
553
+ preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown));
554
+ }
555
+ // additionalProperties: false โ€” the per-key "is this undeclared" test inlines
556
+ // `!==` comparisons for a short key list and hoists a Set only for a long one
557
+ // (see unknownKeyCheck). The predicate form is shared by the fast path and the
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).
557
564
  const strictKeys = hasStrictKeys(schema);
558
- if (strictKeys) {
559
- preamble.push(`const _knownKeys${typeName} = new Set(${JSON.stringify(properties.map(([key]) => key))});`);
560
- preamble.push(`const _hasOnlyKnownKeys${typeName} = (input: Record<string, unknown>): boolean => {\n for (const _k in input) if (!_knownKeys${typeName}.has(_k)) return false;\n return true;\n};`);
565
+ const stripKeys = strictKeys || stripUnknown;
566
+ const strictKeyCheck = unknownKeyCheck(properties.map(([key]) => key), `_knownKeys${typeName}`);
567
+ if (stripKeys) {
568
+ for (const declaration of strictKeyCheck.declarations) {
569
+ preamble.push(`${declaration};`);
570
+ }
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};`);
561
572
  }
562
573
  const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
563
574
  const lines = [];
@@ -568,7 +579,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
568
579
  }
569
580
  if (strictKeys) {
570
581
  lines.push(` for (const _k in input) {`);
571
- lines.push(` if (!_knownKeys${typeName}.has(_k)) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
582
+ lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
572
583
  lines.push(` }`);
573
584
  }
574
585
  }
@@ -617,10 +628,11 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
617
628
  }
618
629
  }
619
630
  }
620
- // The fast path returns `{ ...input }`, so with additionalProperties: false
621
- // it must only fire when no undeclared keys are present. Strict mode already
622
- // threw on them above.
623
- if (strictKeys && !strict) {
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)) {
624
636
  fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
625
637
  }
626
638
  // Second pass: generate variable declarations only for properties that need them
@@ -631,10 +643,12 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
631
643
  }
632
644
  // Emit a warning for any input key not declared in the schema's properties
633
645
  if (logWarnings && propInfo.length > 0) {
634
- const keysList = propInfo.map(({ key }) => JSON.stringify(key)).join(', ');
635
- lines.push(` const _knownKeys = new Set([${keysList}]);`);
646
+ const warnKeyCheck = unknownKeyCheck(propInfo.map(({ key }) => key), '_knownKeys');
647
+ for (const declaration of warnKeyCheck.declarations) {
648
+ lines.push(` ${declaration};`);
649
+ }
636
650
  lines.push(` for (const _k in input) {`);
637
- lines.push(` if (!_knownKeys.has(_k)) {`);
651
+ lines.push(` if (${warnKeyCheck.isUnknown('_k')}) {`);
638
652
  lines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`);
639
653
  lines.push(` }`);
640
654
  lines.push(` }`);
@@ -648,10 +662,11 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
648
662
  lines.push(` if (${fastPathExpr}) return { ...input } as ${typeName};`);
649
663
  }
650
664
  // Generate slow-path object construction. The input spread preserves
651
- // undeclared keys, so it is dropped when additionalProperties: false โ€”
652
- // building from the declared properties alone strips them from the result.
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.
653
668
  const objectLines = [];
654
- if (!strictKeys) {
669
+ if (!stripKeys) {
655
670
  objectLines.push(' ...input,');
656
671
  }
657
672
  // Spread allOf $ref parsers so their field coercions are applied before
@@ -747,11 +762,64 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
747
762
  return fn;
748
763
  return `${preamble.join('\n\n')}\n\n${fn}`;
749
764
  };
765
+ /**
766
+ * Combined-parser variant for `properties` + `patternProperties` schemas that
767
+ * set `additionalProperties: false`. Declared properties are coerced as usual,
768
+ * keys matching any pattern are kept (the first `$ref` pattern is coerced via
769
+ * its imported parser when ref imports are on), and every other key is rejected
770
+ * in strict mode or stripped in coerce mode โ€” matching the interpreter.
771
+ */
772
+ const generateStrictCombinedParser = (schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict) => {
773
+ const declaredKeys = hasProperties(schema) ? Object.keys(schema.properties) : [];
774
+ // Below the inline threshold a key === "a" || key === "b" chain skips declared
775
+ // keys without the per-call Set allocation; a wider list hoists the Set.
776
+ const knownKeyCheck = unknownKeyCheck(declaredKeys, '_knownKeys');
777
+ // The first $ref pattern (when ref imports are on) coerces its matching values
778
+ // through the imported parser; the remaining patterns keep the raw value.
779
+ const refPattern = useRefImports ? patterns.find(([, ps]) => isSchemaObject(ps) && hasRef(ps)) : undefined;
780
+ const loopLines = [` if (${knownKeyCheck.isKnown('key')}) continue;`];
781
+ if (refPattern) {
782
+ const parserName = generateParserName(refToName(refPattern[1].$ref, suffix));
783
+ loopLines.push(` if (/${escapeRegexPattern(refPattern[0])}/.test(key)) {`);
784
+ loopLines.push(` (result as Record<string, unknown>)[key] = ${parserName}(input[key]);`);
785
+ loopLines.push(` continue;`);
786
+ loopLines.push(` }`);
787
+ }
788
+ const keepConditions = patterns
789
+ .filter(([p]) => !(refPattern && p === refPattern[0]))
790
+ .map(([p]) => `/${escapeRegexPattern(p)}/.test(key)`);
791
+ if (keepConditions.length > 0) {
792
+ loopLines.push(` if (${keepConditions.join(' || ')}) {`);
793
+ loopLines.push(` (result as Record<string, unknown>)[key] = input[key];`);
794
+ loopLines.push(` continue;`);
795
+ loopLines.push(` }`);
796
+ }
797
+ // Unknown key: throw in strict mode, otherwise let it fall through (dropped,
798
+ // since it was never copied into `result`).
799
+ if (strict)
800
+ loopLines.push(` throw new Error(\`[${typeName}] unknown property "\${key}"\`);`);
801
+ const notObjectBranch = strict
802
+ ? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
803
+ : ` return {} as unknown as ${typeName};`;
804
+ const resultBody = propertyLines.length > 0 ? `{\n${propertyLines.join('\n')}\n }` : '{}';
805
+ // Only the Set form needs a declaration; the inline === chain is stateless.
806
+ const knownKeysDeclaration = knownKeyCheck.declarations.map((decl) => ` ${decl};\n`).join('');
807
+ return `export const ${functionName} = (input: unknown): ${typeName} => {
808
+ if (!isObject(input)) {
809
+ ${notObjectBranch}
810
+ }
811
+ ${knownKeysDeclaration} const result = ${resultBody} as unknown as ${typeName};
812
+ for (const key in input) {
813
+ ${loopLines.join('\n')}
814
+ }
815
+ return result;
816
+ };`;
817
+ };
750
818
  /**
751
819
  * Generates a parser for schemas that have both properties AND patternProperties.
752
820
  * Parses the known properties first, then iterates remaining keys to match patterns.
753
821
  */
754
- const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict) => {
822
+ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false) => {
755
823
  const functionName = generateParserName(typeName);
756
824
  const entries = generatePropertyEntries(schema, useRefImports, suffix);
757
825
  // Build the known property lines for the initial object
@@ -763,13 +831,21 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
763
831
  });
764
832
  // Find the first pattern with a $ref for parser delegation
765
833
  if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
766
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
834
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
767
835
  }
768
836
  const patternProps = schema.patternProperties;
769
837
  const patterns = Object.entries(patternProps);
770
838
  const refPattern = patterns.find(([, ps]) => isSchemaObject(ps) && hasRef(ps));
839
+ const strictKeys = hasAdditionalProperties(schema) && schema.additionalProperties === false;
840
+ // With `additionalProperties: false`, keys matching neither a declared
841
+ // property nor any pattern must be rejected (strict) or stripped (coerce).
842
+ // The blanket `...input` spread used below cannot do that, so build a
843
+ // selective copy that mirrors the interpreter's undeclared-key handling.
844
+ if (strictKeys) {
845
+ return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
846
+ }
771
847
  if (!refPattern || !useRefImports) {
772
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
848
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
773
849
  }
774
850
  const [pattern, patternSchema] = refPattern;
775
851
  const ref = patternSchema.$ref;
@@ -894,6 +970,38 @@ const generatePatternPropertiesParser = (schema, typeName, useRefImports, suffix
894
970
  const notObjectBranch = strict
895
971
  ? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
896
972
  : ` return {} as unknown as ${typeName};`;
973
+ // With `additionalProperties: false`, only keys matching a pattern survive:
974
+ // others are rejected (strict) or stripped (coerce). Start from an empty
975
+ // object rather than spreading every input key.
976
+ const strictKeys = hasAdditionalProperties(schema) && schema.additionalProperties === false;
977
+ if (strictKeys) {
978
+ const loopLines = [` if (/${escapedPattern}/.test(key)) {`];
979
+ if (patternAssignment) {
980
+ loopLines.push(` const value = input[key];`);
981
+ loopLines.push(` ${patternAssignment}`);
982
+ }
983
+ loopLines.push(` continue;`);
984
+ loopLines.push(` }`);
985
+ const keepConditions = patterns.slice(1).map(([p]) => `/${escapeRegexPattern(p)}/.test(key)`);
986
+ if (keepConditions.length > 0) {
987
+ loopLines.push(` if (${keepConditions.join(' || ')}) {`);
988
+ loopLines.push(` (result as Record<string, unknown>)[key] = input[key];`);
989
+ loopLines.push(` continue;`);
990
+ loopLines.push(` }`);
991
+ }
992
+ if (strict)
993
+ loopLines.push(` throw new Error(\`[${typeName}] unknown property "\${key}"\`);`);
994
+ return `export const ${functionName} = (input: unknown): ${typeName} => {
995
+ if (!isObject(input)) {
996
+ ${notObjectBranch}
997
+ }
998
+ const result = {} as unknown as ${typeName};
999
+ for (const key in input) {
1000
+ ${loopLines.join('\n')}
1001
+ }
1002
+ return result;
1003
+ };`;
1004
+ }
897
1005
  // Generate a parser that handles both pattern matching and x- extensions
898
1006
  return `export const ${functionName} = (input: unknown): ${typeName} => {
899
1007
  if (!isObject(input)) {
@@ -916,7 +1024,7 @@ ${notObjectBranch}
916
1024
  * This always generates parser calls regardless of useRefImports setting,
917
1025
  * because conditional logic requires delegating to the appropriate parser.
918
1026
  */
919
- const generateConditionalParser = (schema, typeName, suffix) => {
1027
+ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false) => {
920
1028
  const functionName = generateParserName(typeName);
921
1029
  // Extract the condition and branches
922
1030
  const ifSchema = schema.if;
@@ -931,7 +1039,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
931
1039
  // regular object parser from the merged property set.
932
1040
  const mergedSchema = getConditionalObjectSchema(schema);
933
1041
  if (mergedSchema) {
934
- return generateObjectParser(mergedSchema, typeName, false, suffix);
1042
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
935
1043
  }
936
1044
  return generateEmptyObjectParser(typeName);
937
1045
  }
@@ -945,7 +1053,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
945
1053
  // Condition is not a $ref check โ€” flatten into an object parser
946
1054
  const mergedSchema = getConditionalObjectSchema(schema);
947
1055
  if (mergedSchema) {
948
- return generateObjectParser(mergedSchema, typeName, false, suffix);
1056
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
949
1057
  }
950
1058
  return generateEmptyObjectParser(typeName);
951
1059
  }
@@ -1040,6 +1148,7 @@ const selectParserStrategy = (schema, typeName, options) => {
1040
1148
  const useRefImports = options?.useRefImports ?? false;
1041
1149
  const logWarnings = options?.logWarnings ?? false;
1042
1150
  const strict = options?.strict ?? false;
1151
+ const stripUnknown = options?.stripUnknown ?? false;
1043
1152
  const suffix = options?.typeSuffix ?? '';
1044
1153
  // Special case for the self-referential JSON Schema meta-schema type (e.g.
1045
1154
  // `Schema` / `SchemaObject`) - it can be any JSON Schema.
@@ -1056,24 +1165,24 @@ const selectParserStrategy = (schema, typeName, options) => {
1056
1165
  // This generates a parser that handles known properties and also iterates
1057
1166
  // pattern-matched keys (e.g. responses with "default" + "200", "4XX").
1058
1167
  if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
1059
- return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
1168
+ return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown);
1060
1169
  }
1061
1170
  // Handle schemas that have explicit properties โ€” generate a full object parser.
1062
1171
  // This intentionally runs before if/then checks so that schemas with both
1063
1172
  // properties AND conditional keywords use all declared properties rather than
1064
1173
  // only the if/then fragment.
1065
1174
  if (hasProperties(schema)) {
1066
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
1175
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1067
1176
  }
1068
1177
  // Handle conditional schemas (if/then/else) for schemas without explicit properties.
1069
1178
  if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
1070
- return generateConditionalParser(schema, typeName, suffix);
1179
+ return generateConditionalParser(schema, typeName, suffix, stripUnknown);
1071
1180
  }
1072
1181
  // Handle conditional schemas that only define if/then object fragments.
1073
1182
  // We flatten the fragments into a regular object parser.
1074
1183
  const conditionalObjectSchema = getConditionalObjectSchema(schema);
1075
1184
  if (conditionalObjectSchema) {
1076
- return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict);
1185
+ return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1077
1186
  }
1078
1187
  // Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
1079
1188
  if (!isObjectLikeSchema) {
@@ -1130,7 +1239,7 @@ export const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeN
1130
1239
  * in additionalProperties). The stub is safe: parents calling it will fall
1131
1240
  * through to the slow path, matching the pre-deep-fast-path behavior.
1132
1241
  */
1133
- export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true) => {
1242
+ export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false) => {
1134
1243
  const fnName = shapeValidatorName(typeName);
1135
1244
  const exportPrefix = exported ? 'export ' : '';
1136
1245
  const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
@@ -1153,9 +1262,12 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
1153
1262
  // Same deterministic naming as the parser's sub-parser generation, so the
1154
1263
  // referenced private shape predicates exist in the same file.
1155
1264
  const subTypeNames = collectInlineObjectProperties(schema, typeName);
1156
- // With additionalProperties: false the shape only matches when every key is
1157
- // declared, so the parser's fast path will not smuggle extras through.
1158
- const strictKeysGuard = hasStrictKeys(schema) ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;` : '';
1265
+ // When the parser strips extras (additionalProperties: false or stripUnknown),
1266
+ // an input carrying an undeclared key is *not* fast-path eligible โ€” the parser
1267
+ // would strip it rather than return `{ ...input }` โ€” so the shape only matches
1268
+ // when every key is declared. (The `_hasOnlyKnownKeys` predicate this calls is
1269
+ // emitted by the parser under the same `stripKeys` condition.)
1270
+ const strictKeysGuard = hasStrictKeys(schema) || stripUnknown ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;` : '';
1159
1271
  const checks = [];
1160
1272
  for (const [key, propSchema] of properties) {
1161
1273
  // 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.8.0",
3
+ "version": "0.10.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",
@@ -47,7 +48,11 @@
47
48
  },
48
49
  "dependencies": {
49
50
  "json-schema-typed": "^8.0.1",
50
- "@amritk/helpers": "0.9.0",
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
  }