@amritk/generate-parsers 0.9.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 {};
@@ -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,16 +549,22 @@ 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
  }
@@ -622,10 +628,11 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
622
628
  }
623
629
  }
624
630
  }
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) {
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)) {
629
636
  fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
630
637
  }
631
638
  // Second pass: generate variable declarations only for properties that need them
@@ -655,10 +662,11 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
655
662
  lines.push(` if (${fastPathExpr}) return { ...input } as ${typeName};`);
656
663
  }
657
664
  // 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.
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.
660
668
  const objectLines = [];
661
- if (!strictKeys) {
669
+ if (!stripKeys) {
662
670
  objectLines.push(' ...input,');
663
671
  }
664
672
  // Spread allOf $ref parsers so their field coercions are applied before
@@ -811,7 +819,7 @@ ${loopLines.join('\n')}
811
819
  * Generates a parser for schemas that have both properties AND patternProperties.
812
820
  * Parses the known properties first, then iterates remaining keys to match patterns.
813
821
  */
814
- const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict) => {
822
+ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false) => {
815
823
  const functionName = generateParserName(typeName);
816
824
  const entries = generatePropertyEntries(schema, useRefImports, suffix);
817
825
  // Build the known property lines for the initial object
@@ -823,7 +831,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
823
831
  });
824
832
  // Find the first pattern with a $ref for parser delegation
825
833
  if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
826
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
834
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
827
835
  }
828
836
  const patternProps = schema.patternProperties;
829
837
  const patterns = Object.entries(patternProps);
@@ -837,7 +845,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
837
845
  return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
838
846
  }
839
847
  if (!refPattern || !useRefImports) {
840
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
848
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
841
849
  }
842
850
  const [pattern, patternSchema] = refPattern;
843
851
  const ref = patternSchema.$ref;
@@ -1016,7 +1024,7 @@ ${notObjectBranch}
1016
1024
  * This always generates parser calls regardless of useRefImports setting,
1017
1025
  * because conditional logic requires delegating to the appropriate parser.
1018
1026
  */
1019
- const generateConditionalParser = (schema, typeName, suffix) => {
1027
+ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false) => {
1020
1028
  const functionName = generateParserName(typeName);
1021
1029
  // Extract the condition and branches
1022
1030
  const ifSchema = schema.if;
@@ -1031,7 +1039,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
1031
1039
  // regular object parser from the merged property set.
1032
1040
  const mergedSchema = getConditionalObjectSchema(schema);
1033
1041
  if (mergedSchema) {
1034
- return generateObjectParser(mergedSchema, typeName, false, suffix);
1042
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
1035
1043
  }
1036
1044
  return generateEmptyObjectParser(typeName);
1037
1045
  }
@@ -1045,7 +1053,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
1045
1053
  // Condition is not a $ref check โ€” flatten into an object parser
1046
1054
  const mergedSchema = getConditionalObjectSchema(schema);
1047
1055
  if (mergedSchema) {
1048
- return generateObjectParser(mergedSchema, typeName, false, suffix);
1056
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
1049
1057
  }
1050
1058
  return generateEmptyObjectParser(typeName);
1051
1059
  }
@@ -1140,6 +1148,7 @@ const selectParserStrategy = (schema, typeName, options) => {
1140
1148
  const useRefImports = options?.useRefImports ?? false;
1141
1149
  const logWarnings = options?.logWarnings ?? false;
1142
1150
  const strict = options?.strict ?? false;
1151
+ const stripUnknown = options?.stripUnknown ?? false;
1143
1152
  const suffix = options?.typeSuffix ?? '';
1144
1153
  // Special case for the self-referential JSON Schema meta-schema type (e.g.
1145
1154
  // `Schema` / `SchemaObject`) - it can be any JSON Schema.
@@ -1156,24 +1165,24 @@ const selectParserStrategy = (schema, typeName, options) => {
1156
1165
  // This generates a parser that handles known properties and also iterates
1157
1166
  // pattern-matched keys (e.g. responses with "default" + "200", "4XX").
1158
1167
  if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
1159
- return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
1168
+ return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown);
1160
1169
  }
1161
1170
  // Handle schemas that have explicit properties โ€” generate a full object parser.
1162
1171
  // This intentionally runs before if/then checks so that schemas with both
1163
1172
  // properties AND conditional keywords use all declared properties rather than
1164
1173
  // only the if/then fragment.
1165
1174
  if (hasProperties(schema)) {
1166
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
1175
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1167
1176
  }
1168
1177
  // Handle conditional schemas (if/then/else) for schemas without explicit properties.
1169
1178
  if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
1170
- return generateConditionalParser(schema, typeName, suffix);
1179
+ return generateConditionalParser(schema, typeName, suffix, stripUnknown);
1171
1180
  }
1172
1181
  // Handle conditional schemas that only define if/then object fragments.
1173
1182
  // We flatten the fragments into a regular object parser.
1174
1183
  const conditionalObjectSchema = getConditionalObjectSchema(schema);
1175
1184
  if (conditionalObjectSchema) {
1176
- return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict);
1185
+ return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1177
1186
  }
1178
1187
  // Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
1179
1188
  if (!isObjectLikeSchema) {
@@ -1230,7 +1239,7 @@ export const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeN
1230
1239
  * in additionalProperties). The stub is safe: parents calling it will fall
1231
1240
  * through to the slow path, matching the pre-deep-fast-path behavior.
1232
1241
  */
1233
- export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true) => {
1242
+ export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false) => {
1234
1243
  const fnName = shapeValidatorName(typeName);
1235
1244
  const exportPrefix = exported ? 'export ' : '';
1236
1245
  const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
@@ -1253,9 +1262,12 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
1253
1262
  // Same deterministic naming as the parser's sub-parser generation, so the
1254
1263
  // referenced private shape predicates exist in the same file.
1255
1264
  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;` : '';
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;` : '';
1259
1271
  const checks = [];
1260
1272
  for (const [key, propSchema] of properties) {
1261
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.9.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",
@@ -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
  }