@amritk/generate-parsers 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  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
- 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';
4
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
5
  import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
6
6
  import { getDefaultValue } from '../helpers/get-default-value.js';
7
7
  import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
8
- import { generateValidationExpression } from './generate-validation-expression.js';
8
+ import { generateValidationExpression, scalarItemTypeCheck } from './generate-validation-expression.js';
9
9
  /**
10
10
  * Generates the parser function name from a type name.
11
11
  * Converts "UserObject" to "parseUserObject".
@@ -213,41 +213,10 @@ const generateFallbackValue = (_, propSchema, useRefImports, suffix) => {
213
213
  return '{}';
214
214
  }
215
215
  }
216
- // For non-schema objects, return undefined
217
- if (!isSchemaObject(propSchema)) {
218
- return 'undefined';
219
- }
220
- // Use explicit default if provided
221
- if (hasDefault(propSchema)) {
222
- return JSON.stringify(propSchema.default);
223
- }
224
- // Use first enum value if available
225
- if (hasEnum(propSchema) && propSchema.enum.length > 0) {
226
- return JSON.stringify(propSchema.enum[0]);
227
- }
228
- // Use first example if available
229
- if (hasExamples(propSchema) && propSchema.examples.length > 0) {
230
- return JSON.stringify(propSchema.examples[0]);
231
- }
232
- // Return simple type-based defaults without pattern inference
233
- if (!hasType(propSchema)) {
234
- return 'undefined';
235
- }
236
- switch (propSchema.type) {
237
- case 'string':
238
- return '""';
239
- case 'number':
240
- case 'integer':
241
- return '0';
242
- case 'boolean':
243
- return 'false';
244
- case 'array':
245
- return '[]';
246
- case 'object':
247
- return '{}';
248
- default:
249
- return 'undefined';
250
- }
216
+ // Everything else (default/const/enum/examples/union, the null type, and a
217
+ // recursively-defaulted object) shares one source of truth so the fallback
218
+ // object is itself a valid instance — `getDefaultValue` handles them all.
219
+ return getDefaultValue(propSchema);
251
220
  };
252
221
  /**
253
222
  * Generates a fallback object with required properties filled with default values.
@@ -296,12 +265,85 @@ const generateFallbackObject = (schema, useRefImports, typeName, suffix, subType
296
265
  * Generates a parser for non-object schemas (string, number, boolean, array, etc.)
297
266
  * that validates the input matches the expected primitive type before casting.
298
267
  */
268
+ /**
269
+ * A default value literal (as source text) for `schema`, used as the fallback a
270
+ * top-level union coerces an unmatched value to. Prefers a `const`/`enum` member,
271
+ * then a per-type empty value.
272
+ */
273
+ const scalarDefaultLiteral = (schema) => {
274
+ if (!isSchemaObject(schema))
275
+ return '{}';
276
+ if (hasConst(schema))
277
+ return JSON.stringify(schema.const);
278
+ if (hasEnum(schema) && schema.enum.length > 0)
279
+ return JSON.stringify(schema.enum[0]);
280
+ if (hasType(schema)) {
281
+ switch (schema.type) {
282
+ case 'string':
283
+ return '""';
284
+ case 'number':
285
+ case 'integer':
286
+ return '0';
287
+ case 'boolean':
288
+ return 'false';
289
+ case 'null':
290
+ return 'null';
291
+ case 'array':
292
+ return '[]';
293
+ case 'object':
294
+ return '{}';
295
+ }
296
+ }
297
+ return '{}';
298
+ };
299
299
  const generateNonObjectParser = (typeName, schema, strict) => {
300
300
  const functionName = generateParserName(typeName);
301
+ // `const`/`enum` define the value space directly (the generated type is a
302
+ // literal / literal-union), so a non-member must coerce to a valid member —
303
+ // otherwise the returned value would not be of the declared type. This runs
304
+ // before the `type` switch so e.g. `{ type: 'string', enum: [...] }` is covered,
305
+ // and before the no-`type` bail so a bare `const`/`enum` schema is covered too.
306
+ if (!strict && isSchemaObject(schema)) {
307
+ if (hasConst(schema)) {
308
+ const literal = JSON.stringify(schema.const);
309
+ // The const is the only valid value, so the parser always yields it. For a
310
+ // primitive we can keep the caller's value when it already equals the const
311
+ // (`===` is a correct comparison); for an object/array, `===` would be a
312
+ // (always-false) reference comparison, so just return the const literal.
313
+ const isPrimitive = schema.const === null || typeof schema.const !== 'object';
314
+ return isPrimitive
315
+ ? `export const ${functionName} = (input: unknown): ${typeName} => input === ${literal} ? input as ${typeName} : ${literal} as ${typeName};`
316
+ : `export const ${functionName} = (input: unknown): ${typeName} => ${literal} as ${typeName};`;
317
+ }
318
+ if (hasEnum(schema) && schema.enum.length > 0) {
319
+ const values = JSON.stringify(schema.enum);
320
+ const fallback = JSON.stringify(schema.enum[0]);
321
+ return `export const ${functionName} = (input: unknown): ${typeName} => ${values}.includes(input as never) ? input as ${typeName} : ${fallback} as ${typeName};`;
322
+ }
323
+ // A top-level union must validate membership: an unmatched value is not of
324
+ // the declared union type, so coerce it to a member-shaped default. Reuse the
325
+ // same union validation the property path uses. A `$ref` branch can't be
326
+ // validated inline here (the membership test would silently drop it and
327
+ // wrongly coerce a valid ref-shaped value), so fall through to a passthrough
328
+ // cast rather than discarding valid input.
329
+ if (hasOneOf(schema) || hasAnyOf(schema)) {
330
+ const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
331
+ const hasRefBranch = branches.some((b) => isSchemaObject(b) && hasRef(b));
332
+ if (branches.length > 0 && !hasRefBranch) {
333
+ const fallback = scalarDefaultLiteral(branches[0]);
334
+ const expr = generateValidationExpression('', schema, fallback, true, undefined, undefined, 'input', true);
335
+ return `export const ${functionName} = (input: unknown): ${typeName} => (${expr}) as ${typeName};`;
336
+ }
337
+ return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
338
+ }
339
+ }
301
340
  if (!isSchemaObject(schema) || !hasType(schema)) {
302
341
  // Schema without type information cannot be validated beyond a cast
303
342
  return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
304
343
  }
344
+ if (!strict && schema.type === 'null') {
345
+ return `export const ${functionName} = (input: unknown): ${typeName} => null as ${typeName};`;
346
+ }
305
347
  if (strict) {
306
348
  const assertion = generateScalarStrictAssertion(schema, typeName);
307
349
  if (assertion === null) {
@@ -318,8 +360,15 @@ const generateNonObjectParser = (typeName, schema, strict) => {
318
360
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" ? input as ${typeName} : 0 as ${typeName};`;
319
361
  case 'boolean':
320
362
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "boolean" ? input as ${typeName} : false as ${typeName};`;
321
- case 'array':
363
+ case 'array': {
364
+ // Coerce each element when the item schema is a single scalar type.
365
+ if (hasItems(schema) && !Array.isArray(schema.items) && scalarItemTypeCheck(schema.items, '_it') !== null) {
366
+ const item = schema.items;
367
+ const itemExpr = generateValidationExpression('', item, getDefaultValue(item), true, undefined, undefined, '_it', true);
368
+ return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? (input as unknown[]).map((_it) => ${itemExpr}) as ${typeName} : [] as ${typeName};`;
369
+ }
322
370
  return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? [...input] as ${typeName} : [] as ${typeName};`;
371
+ }
323
372
  default:
324
373
  return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
325
374
  }
@@ -493,6 +542,14 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
493
542
  if (hasUniqueItems(schema) && schema.uniqueItems === true) {
494
543
  checks.push(`new Set(${varName}).size === ${varName}.length`);
495
544
  }
545
+ // For a scalar item type, every element must already be well-typed to take
546
+ // the fast path; a mistyped element routes the array to the slow path where
547
+ // each element is coerced.
548
+ if (hasItems(schema) && !Array.isArray(schema.items)) {
549
+ const itemCheck = scalarItemTypeCheck(schema.items, '_it');
550
+ if (itemCheck)
551
+ checks.push(`${varName}.every((_it) => ${itemCheck})`);
552
+ }
496
553
  break;
497
554
  }
498
555
  case 'object':
@@ -1,4 +1,11 @@
1
1
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ /**
3
+ * A boolean type check for an array element whose schema is a single scalar type
4
+ * (the cases the element coercion below can handle). `integer` is treated as
5
+ * `number` because the generated TS element type is `number`. Returns `null` for
6
+ * objects, arrays, unions, `$ref`s, and anything richer.
7
+ */
8
+ export declare const scalarItemTypeCheck: (itemSchema: JSONSchema, accessor: string) => string | null;
2
9
  /**
3
10
  * Generates a validation expression that checks if a value matches the expected type.
4
11
  * Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
@@ -2,8 +2,9 @@ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
3
  import { resolveRef } from '@amritk/helpers/resolve-ref';
4
4
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
5
- import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
6
6
  import { findDiscriminator } from '../helpers/find-discriminator.js';
7
+ import { getDefaultValue } from '../helpers/get-default-value.js';
7
8
  import { generateDiscriminatedUnionValidation } from './generate-discriminated-union-validation.js';
8
9
  import { generateEnumCheck } from './generate-enum-check.js';
9
10
  import { generateNonDiscriminatedUnionValidation } from './generate-non-discriminated-union-validation.js';
@@ -19,6 +20,29 @@ const getInstanceCoercion = (accessor, instanceOf) => {
19
20
  return `new Date(${accessor} as string | number | Date)`;
20
21
  return null;
21
22
  };
23
+ /**
24
+ * A boolean type check for an array element whose schema is a single scalar type
25
+ * (the cases the element coercion below can handle). `integer` is treated as
26
+ * `number` because the generated TS element type is `number`. Returns `null` for
27
+ * objects, arrays, unions, `$ref`s, and anything richer.
28
+ */
29
+ export const scalarItemTypeCheck = (itemSchema, accessor) => {
30
+ if (!isSchemaObject(itemSchema) || !hasType(itemSchema) || hasEnum(itemSchema))
31
+ return null;
32
+ switch (itemSchema.type) {
33
+ case 'string':
34
+ return `typeof ${accessor} === "string"`;
35
+ case 'number':
36
+ case 'integer':
37
+ return `typeof ${accessor} === "number"`;
38
+ case 'boolean':
39
+ return `typeof ${accessor} === "boolean"`;
40
+ case 'null':
41
+ return `${accessor} === null`;
42
+ default:
43
+ return null;
44
+ }
45
+ };
22
46
  /**
23
47
  * Generates a type coercion expression for converting a value to the expected type.
24
48
  */
@@ -41,7 +65,9 @@ const getTypeCoercion = (accessor, schema, defaultValue) => {
41
65
  case 'array':
42
66
  return `[]`;
43
67
  case 'object':
44
- return `typeof ${accessor} === "object" && ${accessor} !== null ? ${accessor} : {}`;
68
+ // Fall back to the schema's default (which fills required properties)
69
+ // rather than a bare `{}`, so a coerced object is a valid instance.
70
+ return `typeof ${accessor} === "object" && ${accessor} !== null ? ${accessor} : ${defaultValue}`;
45
71
  default:
46
72
  return null;
47
73
  }
@@ -148,6 +174,20 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
148
174
  if (!hasType(schema) && !hasEnum(schema)) {
149
175
  return `${accessor} ?? ${defaultValue}`;
150
176
  }
177
+ // An array of scalar items: coerce each element so e.g. `number[]` actually
178
+ // contains numbers. The fast path only takes a well-typed array, so a mistyped
179
+ // element reaches here. ($ref items use the caller's validateArray path;
180
+ // object/union items still pass through.)
181
+ if (hasType(schema) &&
182
+ schema.type === 'array' &&
183
+ hasItems(schema) &&
184
+ !Array.isArray(schema.items) &&
185
+ scalarItemTypeCheck(schema.items, '_it') !== null) {
186
+ const itemSchema = schema.items;
187
+ const itemExpr = generateValidationExpression('', itemSchema, getDefaultValue(itemSchema), true, rootSchema, visitedRefs, '_it', true);
188
+ const mapped = `(Array.isArray(${accessor}) ? (${accessor} as unknown[]).map((_it) => ${itemExpr}) : ${defaultValue})`;
189
+ return isRequired ? mapped : `(${accessor} !== undefined ? ${mapped} : undefined)`;
190
+ }
151
191
  // Build type-specific checks
152
192
  if (hasType(schema)) {
153
193
  switch (schema.type) {
@@ -187,6 +227,9 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
187
227
  case 'boolean':
188
228
  checks.push(`typeof ${accessor} === "boolean"`);
189
229
  break;
230
+ case 'null':
231
+ checks.push(`${accessor} === null`);
232
+ break;
190
233
  case 'array': {
191
234
  checks.push(`Array.isArray(${accessor})`);
192
235
  if (hasMinItems(schema)) {
@@ -241,7 +284,12 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
241
284
  // Generate the fallback value
242
285
  // If the value exists but fails validation, try to coerce it
243
286
  // If the value is missing and required, use the default
244
- const typeCoercion = getTypeCoercion(accessor, schema, defaultValue);
287
+ //
288
+ // `enum` constrains the value to a fixed set, so type coercion (e.g.
289
+ // `String(x)`) can't rescue a non-member — `String("z")` is still not in the
290
+ // enum. Fall back to the default (the first enum value) so the result is always
291
+ // a valid member of the declared literal-union type.
292
+ const typeCoercion = hasEnum(schema) ? null : getTypeCoercion(accessor, schema, defaultValue);
245
293
  if (isRequired) {
246
294
  // For required fields: valid ? use_value : (exists ? coerce : default)
247
295
  if (typeCoercion) {
@@ -1,5 +1,5 @@
1
1
  import { getMjstPrimitive } from '@amritk/helpers/mjst-extension';
2
- import { hasAllOf, hasAnyOf, hasDefault, hasEnum, hasExamples, hasOneOf, hasPattern, hasType, isSchemaObject, } from '@amritk/helpers/schema-guards';
2
+ import { hasAllOf, hasAnyOf, hasConst, hasDefault, hasEnum, hasExamples, hasOneOf, hasPattern, hasProperties, hasRequired, hasType, isSchemaObject, } from '@amritk/helpers/schema-guards';
3
3
  import { generateDefaultFromPattern } from '../generators/generate-default-from-pattern.js';
4
4
  /**
5
5
  * Returns the default value for a JSON Schema property.
@@ -14,6 +14,10 @@ export const getDefaultValue = (schema) => {
14
14
  if (hasDefault(schema)) {
15
15
  return JSON.stringify(schema.default);
16
16
  }
17
+ // A `const` value is the only valid value, so it is also the default.
18
+ if (hasConst(schema)) {
19
+ return JSON.stringify(schema.const);
20
+ }
17
21
  // Use first enum value if available
18
22
  if (hasEnum(schema) && schema.enum.length > 0) {
19
23
  return JSON.stringify(schema.enum[0]);
@@ -64,10 +68,28 @@ export const getDefaultValue = (schema) => {
64
68
  return '0';
65
69
  case 'boolean':
66
70
  return 'false';
71
+ case 'null':
72
+ return 'null';
67
73
  case 'array':
68
74
  return '[]';
69
- case 'object':
75
+ case 'object': {
76
+ // A bare `{}` omits required properties, leaving the default invalid against
77
+ // its own type. Populate each required property with its own default so the
78
+ // fallback object is itself a valid instance.
79
+ if (hasProperties(schema) && hasRequired(schema)) {
80
+ const props = schema.properties;
81
+ const required = schema.required.filter((key) => Object.hasOwn(props, key));
82
+ const defaults = required.map((key) => getDefaultValue(props[key]));
83
+ // A required property whose schema has no concrete default (a `$ref` we
84
+ // can't resolve here, or a type-less schema) would otherwise emit
85
+ // `"key": undefined` — i.e. a *missing* required key. Don't build a
86
+ // partial object with holes; fall back to a bare `{}` for the whole node.
87
+ if (defaults.length > 0 && !defaults.includes('undefined')) {
88
+ return `{ ${required.map((key, i) => `${JSON.stringify(key)}: ${defaults[i]}`).join(', ')} }`;
89
+ }
90
+ }
70
91
  return '{}';
92
+ }
71
93
  default:
72
94
  return 'undefined';
73
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -48,11 +48,12 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "json-schema-typed": "^8.0.1",
51
- "@amritk/helpers": "0.10.0",
52
- "@amritk/generate-markdown": "0.2.4"
51
+ "@amritk/helpers": "0.10.1",
52
+ "@amritk/generate-markdown": "0.3.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@sinclair/typebox": "^0.34.49",
56
+ "ajv": "^8.17.1",
56
57
  "zod": "^4.4.3"
57
58
  }
58
59
  }