@amritk/generate-parsers 0.14.0 → 0.16.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
@@ -9,7 +9,7 @@
9
9
  ![license](https://img.shields.io/badge/license-MIT-22c55e?style=flat-square) 
10
10
  ![JSON Schema](https://img.shields.io/badge/JSON%20Schema-2020--12-f97316?style=flat-square) 
11
11
  ![node](https://img.shields.io/badge/node-%E2%89%A520-339933?style=flat-square&logo=node.js&logoColor=white) 
12
- ![vibe coded](https://img.shields.io/badge/vibe%20coded-84%25-a855f7?style=flat-square)
12
+ ![vibe coded](https://img.shields.io/badge/vibe-coded-a855f7?style=flat-square)
13
13
 
14
14
  </div>
15
15
 
@@ -134,6 +134,14 @@ Returns: `Promise<GeneratedFile[]>`.
134
134
  <tr>
135
135
  <td colspan="3">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>
136
136
  </tr>
137
+ <tr>
138
+ <td>🔡 <code>caseInsensitive</code></td>
139
+ <td><code>boolean</code></td>
140
+ <td align="center"><code>false</code></td>
141
+ </tr>
142
+ <tr>
143
+ <td colspan="3">Normalize a mis-cased string to the exact casing of a declared enum/const member it matches case-insensitively (e.g. hElLo → hello) instead of coercing to the default. Coerce mode only — strict parsers still reject a casing mismatch. Correctly-cased input keeps the exact-match fast path, so the hot path is unaffected.</td>
144
+ </tr>
137
145
  </tbody>
138
146
  </table>
139
147
  <!-- config-table-end -->
@@ -48,8 +48,17 @@ export type GeneratedFile = {
48
48
  * is used verbatim and is not affected by this suffix.
49
49
  * @param importExt - Extension used on every relative import specifier in the generated
50
50
  * output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
51
- * `'js'` (default) is the standard TS NodeNext form; `'ts'` emits the literal on-disk
52
- * paths so the generated sources run directly under Node's type stripping.
51
+ * `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
52
+ * literal on-disk paths so the generated sources run directly under Node's type
53
+ * stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
54
+ * under `--build`, where tsc must compile the sources) so generated output runs under
55
+ * Node without a build step; direct callers of `buildSchema` opt in explicitly.
56
+ * @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
57
+ * string to the exact casing of a declared `enum`/`const` member it matches
58
+ * case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
59
+ * mode only — strict parsers still reject a casing mismatch. The normalization lives on
60
+ * the coercion failure branch, so correctly-cased input keeps the exact-match fast path
61
+ * and the hot path is unaffected.
53
62
  * @returns An array of generated TypeScript files
54
63
  *
55
64
  * @example
@@ -82,4 +91,4 @@ export type GeneratedFile = {
82
91
  * });
83
92
  * ```
84
93
  */
85
- 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, importExt?: ImportExtension) => Promise<GeneratedFile[]>;
94
+ 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, importExt?: ImportExtension, caseInsensitive?: boolean) => Promise<GeneratedFile[]>;
@@ -84,8 +84,17 @@ const readHelperSource = async (helper, importExt) => {
84
84
  * is used verbatim and is not affected by this suffix.
85
85
  * @param importExt - Extension used on every relative import specifier in the generated
86
86
  * output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
87
- * `'js'` (default) is the standard TS NodeNext form; `'ts'` emits the literal on-disk
88
- * paths so the generated sources run directly under Node's type stripping.
87
+ * `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
88
+ * literal on-disk paths so the generated sources run directly under Node's type
89
+ * stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
90
+ * under `--build`, where tsc must compile the sources) so generated output runs under
91
+ * Node without a build step; direct callers of `buildSchema` opt in explicitly.
92
+ * @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
93
+ * string to the exact casing of a declared `enum`/`const` member it matches
94
+ * case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
95
+ * mode only — strict parsers still reject a casing mismatch. The normalization lives on
96
+ * the coercion failure branch, so correctly-cased input keeps the exact-match fast path
97
+ * and the hot path is unaffected.
89
98
  * @returns An array of generated TypeScript files
90
99
  *
91
100
  * @example
@@ -118,7 +127,7 @@ const readHelperSource = async (helper, importExt) => {
118
127
  * });
119
128
  * ```
120
129
  */
121
- export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js') => {
130
+ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js', caseInsensitive = false) => {
122
131
  const files = [];
123
132
  const usedHelpers = new Set();
124
133
  walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
@@ -137,10 +146,12 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
137
146
  readonly,
138
147
  typeSuffix,
139
148
  importExt,
149
+ isRoot: node.isRoot,
140
150
  ...(node.ref !== undefined ? { selfRef: node.ref } : {}),
141
151
  ...(logWarnings !== undefined ? { logWarnings } : {}),
142
152
  ...(strict !== undefined ? { strict } : {}),
143
153
  ...(stripUnknown ? { stripUnknown } : {}),
154
+ ...(caseInsensitive ? { caseInsensitive } : {}),
144
155
  });
145
156
  files.push({ filename: `${node.filename}.ts`, content: result.content });
146
157
  for (const helper of result.usedHelpers)
@@ -11,3 +11,20 @@
11
11
  * differs from `===`), so the verdict is unchanged.
12
12
  */
13
13
  export declare const generateEnumCheck: (accessor: string, enumValues: readonly unknown[]) => string;
14
+ /**
15
+ * Builds a case-insensitive coercion expression for a string `enum`/`const`
16
+ * value whose exact-match check has already failed. Only the *string* members
17
+ * participate (a number/boolean/null member has no case to fold): a value that
18
+ * matches one case-insensitively normalizes to that member's exact casing, and
19
+ * everything else yields `fallback`.
20
+ *
21
+ * This is emitted ONLY on the already-invalid branch of the coercion ternary, so
22
+ * a correctly-cased value never reaches it — the exact `===` fast path (and the
23
+ * shape validators / deep guards built on it) is unchanged, so the hot path pays
24
+ * nothing. Only a mis-cased value, which today coerces straight to the default,
25
+ * now takes a single `toLowerCase()` and an O(1) map lookup instead.
26
+ *
27
+ * Returns `null` when no member is a string (nothing to fold), so the caller
28
+ * keeps its plain fallback and emits no extra code.
29
+ */
30
+ export declare const generateEnumCaseInsensitiveCoercion: (accessor: string, enumValues: readonly unknown[], fallback: string) => string | null;
@@ -20,3 +20,35 @@ export const generateEnumCheck = (accessor, enumValues) => {
20
20
  const serializedEnum = JSON.stringify(enumValues);
21
21
  return `${serializedEnum}.includes(${accessor} as never)`;
22
22
  };
23
+ /**
24
+ * Builds a case-insensitive coercion expression for a string `enum`/`const`
25
+ * value whose exact-match check has already failed. Only the *string* members
26
+ * participate (a number/boolean/null member has no case to fold): a value that
27
+ * matches one case-insensitively normalizes to that member's exact casing, and
28
+ * everything else yields `fallback`.
29
+ *
30
+ * This is emitted ONLY on the already-invalid branch of the coercion ternary, so
31
+ * a correctly-cased value never reaches it — the exact `===` fast path (and the
32
+ * shape validators / deep guards built on it) is unchanged, so the hot path pays
33
+ * nothing. Only a mis-cased value, which today coerces straight to the default,
34
+ * now takes a single `toLowerCase()` and an O(1) map lookup instead.
35
+ *
36
+ * Returns `null` when no member is a string (nothing to fold), so the caller
37
+ * keeps its plain fallback and emits no extra code.
38
+ */
39
+ export const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
40
+ const byLower = {};
41
+ for (const value of enumValues) {
42
+ if (typeof value !== 'string')
43
+ continue;
44
+ const lower = value.toLowerCase();
45
+ // First writer wins, so declaration order breaks ties between members that
46
+ // fold to the same key (e.g. `"on"` and `"ON"`).
47
+ if (!(lower in byLower))
48
+ byLower[lower] = value;
49
+ }
50
+ if (Object.keys(byLower).length === 0)
51
+ return null;
52
+ const map = JSON.stringify(byLower);
53
+ return `(typeof ${accessor} === "string" ? ((${map} as Record<string, string>)[(${accessor} as string).toLowerCase()] ?? ${fallback}) : ${fallback})`;
54
+ };
@@ -71,6 +71,20 @@ type GenerateFileOptions = {
71
71
  * stripping.
72
72
  */
73
73
  readonly importExt?: ImportExtension;
74
+ /**
75
+ * True when this file is the root document rather than a `$ref`-reached
76
+ * definition. Forwarded to the parser generator so the JSON Schema
77
+ * meta-schema special case (type name `Schema`) never applies to a
78
+ * user-named root.
79
+ */
80
+ readonly isRoot?: boolean;
81
+ /**
82
+ * When true, a mis-cased string that matches a declared `enum`/`const` member
83
+ * case-insensitively is normalized to that member's exact casing (coerce mode
84
+ * only). The normalization is emitted on the coercion failure branch, so
85
+ * already-valid input keeps the exact-match fast path.
86
+ */
87
+ readonly caseInsensitive?: boolean;
74
88
  };
75
89
  /** Result of generating a single parser file. */
76
90
  export type GeneratedFileContent = {
@@ -62,10 +62,12 @@ export const generateFile = (schema, typeName, options) => {
62
62
  useRefImports: true,
63
63
  typeSuffix,
64
64
  reservedNames,
65
+ ...(options?.isRoot !== undefined ? { isRoot: options.isRoot } : {}),
65
66
  ...(rootSchema !== undefined ? { rootSchema } : {}),
66
67
  ...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
67
68
  ...(options?.strict !== undefined ? { strict: options.strict } : {}),
68
69
  ...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
70
+ ...(options?.caseInsensitive !== undefined ? { caseInsensitive: options.caseInsensitive } : {}),
69
71
  });
70
72
  const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
71
73
  const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
@@ -50,6 +50,23 @@ type GenerateParserOptions = {
50
50
  * validate against the wrong schema.
51
51
  */
52
52
  readonly reservedNames?: ReadonlySet<string>;
53
+ /**
54
+ * True when this schema is the root document (not a `$ref`-reached definition).
55
+ * The root type name is user-derived (schema `title`, filename, or `--root-type`),
56
+ * so the JSON Schema meta-schema special case — keyed on the literal name
57
+ * `Schema` — must not apply to it, or a common `schema.json` root would silently
58
+ * generate a validation-free pass-through parser.
59
+ */
60
+ readonly isRoot?: boolean;
61
+ /**
62
+ * When true, a mis-cased string that matches a declared `enum`/`const` member
63
+ * case-insensitively is normalized to that member's exact casing (e.g. `hElLo`
64
+ * → `hello`) instead of coercing to the default. Coerce mode only — strict
65
+ * parsers still reject a casing mismatch. The normalization lives on the
66
+ * failure branch of the coercion ternary, so a correctly-cased value keeps the
67
+ * exact `===` fast path and the hot path is unaffected.
68
+ */
69
+ readonly caseInsensitive?: boolean;
53
70
  };
54
71
  /**
55
72
  * Generates a safe parser as an arrow function that never throws.
@@ -8,6 +8,7 @@ import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
8
8
  import { findDiscriminator } from '../helpers/find-discriminator.js';
9
9
  import { getDefaultValue } from '../helpers/get-default-value.js';
10
10
  import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
11
+ import { generateEnumCaseInsensitiveCoercion } from './generate-enum-check.js';
11
12
  import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
12
13
  import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, isInlineObjectArrayProperty, isInlineObjectProperty, shapeValidatorName, } from './generate-type-checks.js';
13
14
  import { generateValidationExpression, isCoercibleItemSchema } from './generate-validation-expression.js';
@@ -147,7 +148,7 @@ const shouldUseRecordRefImport = (propSchema, useRefImports) => {
147
148
  /**
148
149
  * Generates the value expression for a property based on its schema and options.
149
150
  */
150
- const generatePropertyValue = (key, propSchema, isRequired, useRefImports, suffix) => {
151
+ const generatePropertyValue = (key, propSchema, isRequired, useRefImports, suffix, caseInsensitive = false) => {
151
152
  // Handle direct $ref properties
152
153
  if (shouldUseRefImport(propSchema, useRefImports)) {
153
154
  const ref = propSchema.$ref;
@@ -173,20 +174,20 @@ const generatePropertyValue = (key, propSchema, isRequired, useRefImports, suffi
173
174
  }
174
175
  // Generate standard validation expression
175
176
  const defaultValue = getDefaultValue(propSchema);
176
- const valueExpression = generateValidationExpression(key, propSchema, defaultValue, isRequired);
177
+ const valueExpression = generateValidationExpression(key, propSchema, defaultValue, isRequired, undefined, undefined, undefined, undefined, caseInsensitive);
177
178
  return isRequired ? valueExpression : generateOptionalInlineProperty(key, valueExpression);
178
179
  };
179
180
  /**
180
181
  * Generates property entries for all properties in the schema.
181
182
  */
182
- const generatePropertyEntries = (schema, useRefImports, suffix) => {
183
+ const generatePropertyEntries = (schema, useRefImports, suffix, caseInsensitive = false) => {
183
184
  if (!hasProperties(schema)) {
184
185
  return [];
185
186
  }
186
187
  const entries = [];
187
188
  for (const [key, propSchema] of Object.entries(schema.properties)) {
188
189
  const isRequired = isPropertyRequired(key, schema);
189
- const value = generatePropertyValue(key, propSchema, isRequired, useRefImports, suffix);
190
+ const value = generatePropertyValue(key, propSchema, isRequired, useRefImports, suffix, caseInsensitive);
190
191
  // Determine if the property uses spread syntax (making it optional in the object literal)
191
192
  const isOptional = value.startsWith('...');
192
193
  entries.push({ key, value, isOptional });
@@ -419,7 +420,11 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
419
420
  if (hasEnum(schema) && schema.enum.length > 0) {
420
421
  const values = JSON.stringify(schema.enum);
421
422
  const fallback = JSON.stringify(schema.enum[0]);
422
- return `export const ${functionName} = (input: unknown): ${typeName} => ${values}.includes(input as never) ? input as ${typeName} : ${fallback} as ${typeName};`;
423
+ // Case-insensitive normalization sits on the non-member branch only, so an
424
+ // exact member still returns via the `includes` fast path untouched.
425
+ const ci = unionCtx?.caseInsensitive ? generateEnumCaseInsensitiveCoercion('input', schema.enum, fallback) : null;
426
+ const coerced = ci ? `(${ci})` : fallback;
427
+ return `export const ${functionName} = (input: unknown): ${typeName} => ${values}.includes(input as never) ? input as ${typeName} : ${coerced} as ${typeName};`;
423
428
  }
424
429
  // A top-level union must validate membership: an unmatched value is not of
425
430
  // the declared union type, so coerce it to a member-shaped default. Reuse the
@@ -477,7 +482,7 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
477
482
  const preamble = [
478
483
  `type ${itemName} = ${typeName}[number];`,
479
484
  generateShapeValidator(items, itemName, useRefImports, suffix, false, stripUnknown, reserved),
480
- generateObjectParser(items, itemName, useRefImports, suffix, unionCtx?.logWarnings ?? false, strict, false, stripUnknown, unionCtx?.rootSchema, reserved),
485
+ generateObjectParser(items, itemName, useRefImports, suffix, unionCtx?.logWarnings ?? false, strict, false, stripUnknown, unionCtx?.rootSchema, reserved, unionCtx?.caseInsensitive ?? false),
481
486
  ].join('\n\n');
482
487
  return `${preamble}\n\n${delegated(generateParserName(itemName))}`;
483
488
  }
@@ -511,7 +516,7 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
511
516
  // Coerce each element when the item schema is a single scalar type or an enum.
512
517
  if (hasItems(schema) && !Array.isArray(schema.items) && isCoercibleItemSchema(schema.items)) {
513
518
  const item = schema.items;
514
- const itemExpr = generateValidationExpression('', item, getDefaultValue(item), true, undefined, undefined, '_it', true);
519
+ const itemExpr = generateValidationExpression('', item, getDefaultValue(item), true, undefined, undefined, '_it', true, unionCtx?.caseInsensitive);
515
520
  return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? (input as unknown[]).map((_it) => ${itemExpr}) as ${typeName} : [] as ${typeName};`;
516
521
  }
517
522
  return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? [...input] as ${typeName} : [] as ${typeName};`;
@@ -664,7 +669,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
664
669
  * - Fast path that returns input directly when all properties are already valid
665
670
  * - Slow path with optimized expressions
666
671
  */
667
- const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema, reservedNames = NO_RESERVED_NAMES) => {
672
+ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema, reservedNames = NO_RESERVED_NAMES, caseInsensitive = false) => {
668
673
  const functionName = generateParserName(typeName);
669
674
  const exportPrefix = exported ? 'export ' : '';
670
675
  if (!hasProperties(schema)) {
@@ -690,7 +695,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
690
695
  const propSchema = schemaProps[key];
691
696
  preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
692
697
  preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames));
693
- preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
698
+ preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive));
694
699
  }
695
700
  for (const [key, subName] of subItemNames) {
696
701
  const propSchema = schemaProps[key];
@@ -701,7 +706,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
701
706
  // Hand-rolled loop instead of Array.prototype.every on the guard path — the
702
707
  // callback protocol costs a few percent on element-heavy hot paths.
703
708
  preamble.push(`const _every${subName} = (arr: readonly unknown[]): boolean => {\n for (let i = 0; i < arr.length; i++) if (!${shapeValidatorName(subName)}(arr[i])) return false;\n return true;\n};`);
704
- preamble.push(generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
709
+ preamble.push(generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive));
705
710
  }
706
711
  // additionalProperties: false — the per-key "is this undeclared" test inlines
707
712
  // `!==` comparisons for a short key list and hoists a Set only for a long one
@@ -979,7 +984,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
979
984
  // For optional properties we know the value is not undefined because
980
985
  // we're inside the `...(accessor !== undefined && { ... })` check.
981
986
  const knownNotUndefined = !isRequired;
982
- valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined);
987
+ valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined, caseInsensitive);
983
988
  }
984
989
  if (isRequired) {
985
990
  objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
@@ -1193,9 +1198,9 @@ ${loopLines.join('\n')}
1193
1198
  * Generates a parser for schemas that have both properties AND patternProperties.
1194
1199
  * Parses the known properties first, then iterates remaining keys to match patterns.
1195
1200
  */
1196
- const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false) => {
1201
+ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false, caseInsensitive = false) => {
1197
1202
  const functionName = generateParserName(typeName);
1198
- const entries = generatePropertyEntries(schema, useRefImports, suffix);
1203
+ const entries = generatePropertyEntries(schema, useRefImports, suffix, caseInsensitive);
1199
1204
  // Build the known property lines for the initial object
1200
1205
  const propertyLines = entries.map((entry) => {
1201
1206
  if (entry.isOptional) {
@@ -1205,7 +1210,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
1205
1210
  });
1206
1211
  // Find the first pattern with a $ref for parser delegation
1207
1212
  if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
1208
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1213
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
1209
1214
  }
1210
1215
  const patternProps = schema.patternProperties;
1211
1216
  const patterns = Object.entries(patternProps);
@@ -1219,7 +1224,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
1219
1224
  return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
1220
1225
  }
1221
1226
  if (!refPattern || !useRefImports) {
1222
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1227
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
1223
1228
  }
1224
1229
  const [pattern, patternSchema] = refPattern;
1225
1230
  const ref = patternSchema.$ref;
@@ -1398,7 +1403,7 @@ ${notObjectBranch}
1398
1403
  * This always generates parser calls regardless of useRefImports setting,
1399
1404
  * because conditional logic requires delegating to the appropriate parser.
1400
1405
  */
1401
- const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false) => {
1406
+ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false, caseInsensitive = false) => {
1402
1407
  const functionName = generateParserName(typeName);
1403
1408
  // Extract the condition and branches
1404
1409
  const ifSchema = schema.if;
@@ -1413,7 +1418,7 @@ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = fals
1413
1418
  // regular object parser from the merged property set.
1414
1419
  const mergedSchema = getConditionalObjectSchema(schema);
1415
1420
  if (mergedSchema) {
1416
- return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
1421
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
1417
1422
  }
1418
1423
  return generateEmptyObjectParser(typeName);
1419
1424
  }
@@ -1427,7 +1432,7 @@ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = fals
1427
1432
  // Condition is not a $ref check — flatten into an object parser
1428
1433
  const mergedSchema = getConditionalObjectSchema(schema);
1429
1434
  if (mergedSchema) {
1430
- return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
1435
+ return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
1431
1436
  }
1432
1437
  return generateEmptyObjectParser(typeName);
1433
1438
  }
@@ -1523,10 +1528,15 @@ const selectParserStrategy = (schema, typeName, options) => {
1523
1528
  const logWarnings = options?.logWarnings ?? false;
1524
1529
  const strict = options?.strict ?? false;
1525
1530
  const stripUnknown = options?.stripUnknown ?? false;
1531
+ const caseInsensitive = options?.caseInsensitive ?? false;
1526
1532
  const suffix = options?.typeSuffix ?? '';
1527
1533
  // Special case for the self-referential JSON Schema meta-schema type (e.g.
1528
- // `Schema` / `SchemaObject`) - it can be any JSON Schema.
1529
- if (typeName === `Schema${suffix}`) {
1534
+ // `Schema` / `SchemaObject`) - it can be any JSON Schema. This is an OpenAPI
1535
+ // heuristic for a `$defs`/`components.schemas` entry named `schema`; it must
1536
+ // never fire for the root document, whose name is user-derived (a `schema.json`
1537
+ // file naturally yields the root type `Schema`) and would otherwise collapse
1538
+ // to a validation-free pass-through parser.
1539
+ if (!options?.isRoot && typeName === `Schema${suffix}`) {
1530
1540
  return generateSchemaObjectParser(typeName);
1531
1541
  }
1532
1542
  const isObjectLikeSchema = isObjectSchema(schema) ||
@@ -1537,6 +1547,7 @@ const selectParserStrategy = (schema, typeName, options) => {
1537
1547
  useRefImports,
1538
1548
  suffix,
1539
1549
  stripUnknown,
1550
+ caseInsensitive,
1540
1551
  logWarnings,
1541
1552
  ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1542
1553
  ...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
@@ -1546,24 +1557,24 @@ const selectParserStrategy = (schema, typeName, options) => {
1546
1557
  // This generates a parser that handles known properties and also iterates
1547
1558
  // pattern-matched keys (e.g. responses with "default" + "200", "4XX").
1548
1559
  if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
1549
- return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown);
1560
+ return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown, caseInsensitive);
1550
1561
  }
1551
1562
  // Handle schemas that have explicit properties — generate a full object parser.
1552
1563
  // This intentionally runs before if/then checks so that schemas with both
1553
1564
  // properties AND conditional keywords use all declared properties rather than
1554
1565
  // only the if/then fragment.
1555
1566
  if (hasProperties(schema)) {
1556
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema, options?.reservedNames);
1567
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema, options?.reservedNames, caseInsensitive);
1557
1568
  }
1558
1569
  // Handle conditional schemas (if/then/else) for schemas without explicit properties.
1559
1570
  if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
1560
- return generateConditionalParser(schema, typeName, suffix, stripUnknown);
1571
+ return generateConditionalParser(schema, typeName, suffix, stripUnknown, caseInsensitive);
1561
1572
  }
1562
1573
  // Handle conditional schemas that only define if/then object fragments.
1563
1574
  // We flatten the fragments into a regular object parser.
1564
1575
  const conditionalObjectSchema = getConditionalObjectSchema(schema);
1565
1576
  if (conditionalObjectSchema) {
1566
- return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1577
+ return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
1567
1578
  }
1568
1579
  // Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
1569
1580
  if (!isObjectLikeSchema) {
@@ -1571,6 +1582,7 @@ const selectParserStrategy = (schema, typeName, options) => {
1571
1582
  useRefImports,
1572
1583
  suffix,
1573
1584
  stripUnknown,
1585
+ caseInsensitive,
1574
1586
  logWarnings,
1575
1587
  ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1576
1588
  ...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
@@ -18,4 +18,4 @@ export declare const isCoercibleItemSchema: (itemSchema: JSONSchema) => boolean;
18
18
  * Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
19
19
  * Supports type checking, pattern validation, enum validation, union types, and $ref resolution.
20
20
  */
21
- export declare const generateValidationExpression: (key: string, schema: JSONSchema, defaultValue: string, isRequired: boolean, rootSchema?: Record<string, unknown>, visitedRefs?: Set<string>, accessorOverride?: string, knownNotUndefined?: boolean) => string;
21
+ export declare const generateValidationExpression: (key: string, schema: JSONSchema, defaultValue: string, isRequired: boolean, rootSchema?: Record<string, unknown>, visitedRefs?: Set<string>, accessorOverride?: string, knownNotUndefined?: boolean, caseInsensitive?: boolean) => string;
@@ -7,7 +7,7 @@ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExcl
7
7
  import { findDiscriminator } from '../helpers/find-discriminator.js';
8
8
  import { getDefaultValue } from '../helpers/get-default-value.js';
9
9
  import { generateDiscriminatedUnionValidation } from './generate-discriminated-union-validation.js';
10
- import { generateEnumCheck } from './generate-enum-check.js';
10
+ import { generateEnumCaseInsensitiveCoercion, generateEnumCheck } from './generate-enum-check.js';
11
11
  import { generateNonDiscriminatedUnionValidation } from './generate-non-discriminated-union-validation.js';
12
12
  import { generateSchemaChecks } from './generate-schema-checks.js';
13
13
  /**
@@ -128,7 +128,7 @@ const getTypeCoercion = (accessor, schema, defaultValue) => {
128
128
  * Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
129
129
  * Supports type checking, pattern validation, enum validation, union types, and $ref resolution.
130
130
  */
131
- export const generateValidationExpression = (key, schema, defaultValue, isRequired, rootSchema, visitedRefs, accessorOverride, knownNotUndefined) => {
131
+ export const generateValidationExpression = (key, schema, defaultValue, isRequired, rootSchema, visitedRefs, accessorOverride, knownNotUndefined, caseInsensitive) => {
132
132
  const accessor = accessorOverride ?? safeAccessor('input?', key);
133
133
  const checks = [];
134
134
  if (!isSchemaObject(schema)) {
@@ -170,7 +170,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
170
170
  if (resolvedSchema) {
171
171
  const visited = new Set(visitedRefs);
172
172
  visited.add(ref);
173
- return generateValidationExpression(key, resolvedSchema, defaultValue, isRequired, rootSchema, visited);
173
+ return generateValidationExpression(key, resolvedSchema, defaultValue, isRequired, rootSchema, visited, undefined, undefined, caseInsensitive);
174
174
  }
175
175
  }
176
176
  // Handle union types (oneOf, anyOf)
@@ -250,7 +250,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
250
250
  !Array.isArray(schema.items) &&
251
251
  isCoercibleItemSchema(schema.items)) {
252
252
  const itemSchema = schema.items;
253
- const itemExpr = generateValidationExpression('', itemSchema, getDefaultValue(itemSchema), true, rootSchema, visitedRefs, '_it', true);
253
+ const itemExpr = generateValidationExpression('', itemSchema, getDefaultValue(itemSchema), true, rootSchema, visitedRefs, '_it', true, caseInsensitive);
254
254
  const mapped = `(Array.isArray(${accessor}) ? (${accessor} as unknown[]).map((_it) => ${itemExpr}) : ${defaultValue})`;
255
255
  return isRequired ? mapped : `(${accessor} !== undefined ? ${mapped} : undefined)`;
256
256
  }
@@ -361,6 +361,14 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
361
361
  // enum. Fall back to the default (the first enum value) so the result is always
362
362
  // a valid member of the declared literal-union type.
363
363
  const typeCoercion = hasEnum(schema) ? null : getTypeCoercion(accessor, schema, defaultValue);
364
+ // Case-insensitive enum normalization is spliced into the *failure* branch of
365
+ // the ternary only, so an already-valid (exactly-cased) member takes the
366
+ // `${accessor}` fast branch untouched. A mis-cased string folds to the exact
367
+ // member; anything else falls through to the branch's normal fallback. `null`
368
+ // when the flag is off or no member is a string, so the plain fallback stands.
369
+ const enumFallback = (fallback) => (caseInsensitive && hasEnum(schema) && schema.enum.length > 0
370
+ ? generateEnumCaseInsensitiveCoercion(accessor, schema.enum, fallback)
371
+ : null) ?? fallback;
364
372
  if (isRequired) {
365
373
  // For required fields: valid ? use_value : (exists ? coerce : default)
366
374
  if (typeCoercion) {
@@ -371,7 +379,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
371
379
  }
372
380
  return `${combinedCheck} ? ${accessor} : (${accessor} !== undefined ? ${typeCoercion} : ${defaultValue})`;
373
381
  }
374
- return `${combinedCheck} ? ${accessor} : ${defaultValue}`;
382
+ return `${combinedCheck} ? ${accessor} : ${enumFallback(defaultValue)}`;
375
383
  }
376
384
  else {
377
385
  // For optional fields: valid ? use_value : (exists ? coerce : undefined)
@@ -382,6 +390,6 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
382
390
  }
383
391
  return `${combinedCheck} ? ${accessor} : (${accessor} !== undefined ? ${typeCoercion} : undefined)`;
384
392
  }
385
- return `${combinedCheck} ? ${accessor} : undefined`;
393
+ return `${combinedCheck} ? ${accessor} : ${enumFallback('undefined')}`;
386
394
  }
387
395
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "json-schema-typed": "^8.0.1",
51
- "@amritk/helpers": "0.11.0",
51
+ "@amritk/helpers": "0.12.0",
52
52
  "@amritk/generate-markdown": "0.4.1"
53
53
  },
54
54
  "devDependencies": {