@amritk/generate-parsers 0.2.1 → 0.3.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.
Files changed (38) hide show
  1. package/dist/generators/build-schema.d.ts +5 -1
  2. package/dist/generators/generate-files.d.ts +15 -2
  3. package/dist/helpers/collect-helpers.d.ts +16 -2
  4. package/dist/index.js +64 -19
  5. package/package.json +3 -2
  6. package/src/generators/build-schema.test.ts +830 -0
  7. package/src/generators/build-schema.ts +280 -0
  8. package/src/generators/generate-default-from-pattern.test.ts +81 -0
  9. package/src/generators/generate-default-from-pattern.ts +39 -0
  10. package/src/generators/generate-discriminated-union-validation.test.ts +84 -0
  11. package/src/generators/generate-discriminated-union-validation.ts +54 -0
  12. package/src/generators/generate-enum-check.test.ts +45 -0
  13. package/src/generators/generate-enum-check.ts +8 -0
  14. package/src/generators/generate-files.test.ts +435 -0
  15. package/src/generators/generate-files.ts +136 -0
  16. package/src/generators/generate-non-discriminated-union-validation.test.ts +74 -0
  17. package/src/generators/generate-non-discriminated-union-validation.ts +40 -0
  18. package/src/generators/generate-parser-function.test.ts +1798 -0
  19. package/src/generators/generate-parser-function.ts +1319 -0
  20. package/src/generators/generate-schema-checks.test.ts +322 -0
  21. package/src/generators/generate-schema-checks.ts +114 -0
  22. package/src/generators/generate-strict-assertion.ts +210 -0
  23. package/src/generators/generate-validation-expression.test.ts +794 -0
  24. package/src/generators/generate-validation-expression.ts +286 -0
  25. package/src/helpers/apply-schema-extensions.test.ts +194 -0
  26. package/src/helpers/apply-schema-extensions.ts +63 -0
  27. package/src/helpers/collect-helpers.test.ts +103 -0
  28. package/src/helpers/collect-helpers.ts +64 -0
  29. package/src/helpers/collect-imports.test.ts +587 -0
  30. package/src/helpers/collect-imports.ts +268 -0
  31. package/src/helpers/find-discriminator.test.ts +88 -0
  32. package/src/helpers/find-discriminator.ts +39 -0
  33. package/src/helpers/get-default-value.test.ts +135 -0
  34. package/src/helpers/get-default-value.ts +89 -0
  35. package/src/helpers/get-discriminator-value.test.ts +83 -0
  36. package/src/helpers/get-discriminator-value.ts +21 -0
  37. package/src/index.ts +1 -0
  38. package/src/types/schema-extensions.ts +35 -0
@@ -0,0 +1,1319 @@
1
+ import { refToName } from '@amritk/helpers/ref-to-name'
2
+ import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor'
3
+ import {
4
+ hasAllOf,
5
+ hasAnyOf,
6
+ hasConst,
7
+ hasDefault,
8
+ hasEnum,
9
+ hasExamples,
10
+ hasExclusiveMaximum,
11
+ hasExclusiveMinimum,
12
+ hasItems,
13
+ hasMaxItems,
14
+ hasMaximum,
15
+ hasMaxLength,
16
+ hasMinItems,
17
+ hasMinimum,
18
+ hasMinLength,
19
+ hasMultipleOf,
20
+ hasOneOf,
21
+ hasPattern,
22
+ hasProperties,
23
+ hasRef,
24
+ hasType,
25
+ hasUniqueItems,
26
+ isObjectSchema,
27
+ isSchemaObject,
28
+ } from '@amritk/helpers/schema-guards'
29
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
30
+ import { getDefaultValue } from '#helpers/get-default-value'
31
+
32
+ import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion'
33
+ import { generateValidationExpression } from './generate-validation-expression'
34
+
35
+ /**
36
+ * Options for controlling parser function generation behavior.
37
+ */
38
+ type GenerateParserOptions = {
39
+ /**
40
+ * When true, properties with $ref will call the imported parser function
41
+ * instead of inlining the validation logic. This is used when generating
42
+ * files where each ref has its own separate file with a parser.
43
+ */
44
+ readonly useRefImports?: boolean
45
+ /**
46
+ * When true, the generated parser emits a console.warn for every input key
47
+ * that is not declared in the schema's properties.
48
+ */
49
+ readonly logWarnings?: boolean
50
+ /**
51
+ * When true, the generated parser throws on type/shape mismatches instead
52
+ * of coercing invalid input to default values. Throws on the first violation
53
+ * with a path-aware Error. Unknown extra keys are still allowed.
54
+ */
55
+ readonly strict?: boolean
56
+ }
57
+
58
+ /**
59
+ * Represents a property in the generated object literal.
60
+ */
61
+ type PropertyEntry = {
62
+ readonly key: string
63
+ readonly value: string
64
+ readonly isOptional: boolean
65
+ }
66
+
67
+ /**
68
+ * Generates the parser function name from a type name.
69
+ * Converts "UserObject" to "parseUserObject".
70
+ */
71
+ const generateParserName = (typeName: string): string => {
72
+ return `parse${typeName}`
73
+ }
74
+
75
+ /**
76
+ * Checks if a property is required based on the schema's required array.
77
+ */
78
+ const isPropertyRequired = (key: string, schema: JSONSchema): boolean => {
79
+ if (!isSchemaObject(schema)) {
80
+ return false
81
+ }
82
+ if (!('required' in schema) || !Array.isArray(schema.required)) {
83
+ return false
84
+ }
85
+ return schema.required.includes(key)
86
+ }
87
+
88
+ /**
89
+ * Generates a parser call expression for a required $ref property.
90
+ * Example: parseContactObject(input.contact)
91
+ */
92
+ const generateRequiredRefCall = (key: string, ref: string): string => {
93
+ const acc = safeAccessor('input', key)
94
+ const parserName = generateParserName(refToName(ref))
95
+ return `${parserName}(${acc})`
96
+ }
97
+
98
+ /**
99
+ * Generates a parser call expression for an optional $ref property.
100
+ * Example: ...(input.contact && { contact: parseContactObject(input.contact) })
101
+ */
102
+ const generateOptionalRefCall = (key: string, ref: string): string => {
103
+ const acc = safeAccessor('input', key)
104
+ const parserName = generateParserName(refToName(ref))
105
+ return `...(${acc} && { ${safeKey(key)}: ${parserName}(${acc}) })`
106
+ }
107
+
108
+ /**
109
+ * Generates a validateArray call for required array properties with $ref items.
110
+ * Example: validateArray(input.contacts, parseContactObject)
111
+ */
112
+ const generateRequiredArrayRefCall = (key: string, ref: string): string => {
113
+ const parserName = generateParserName(refToName(ref))
114
+ return `validateArray(${safeAccessor('input', key)}, ${parserName})`
115
+ }
116
+
117
+ /**
118
+ * Generates a validateArray call for optional array properties with $ref items.
119
+ * Example: ...(input.contacts && { contacts: validateArray(input.contacts, parseContactObject) })
120
+ */
121
+ const generateOptionalArrayRefCall = (key: string, ref: string): string => {
122
+ const parserName = generateParserName(refToName(ref))
123
+ const acc = safeAccessor('input', key)
124
+ return `...(${acc} && { ${safeKey(key)}: validateArray(${acc}, ${parserName}) })`
125
+ }
126
+
127
+ /**
128
+ * Generates a validateRecord call for required object properties with additionalProperties $ref.
129
+ * Example: validateRecord(input.responses, parseResponseObject)
130
+ */
131
+ const generateRequiredRecordRefCall = (key: string, ref: string): string => {
132
+ const acc = safeAccessor('input', key)
133
+ const parserName = generateParserName(refToName(ref))
134
+ return `validateRecord(${acc}, ${parserName})`
135
+ }
136
+
137
+ /**
138
+ * Generates a validateRecord call for optional object properties with additionalProperties $ref.
139
+ * Example: ...(input.responses && { responses: validateRecord(input.responses, parseResponseObject) })
140
+ */
141
+ const generateOptionalRecordRefCall = (key: string, ref: string): string => {
142
+ const acc = safeAccessor('input', key)
143
+ const parserName = generateParserName(refToName(ref))
144
+ return `...(${acc} && { ${safeKey(key)}: validateRecord(${acc}, ${parserName}) })`
145
+ }
146
+
147
+ /**
148
+ * Generates a spread entry for optional inline properties.
149
+ * The value expression is evaluated once and omitted when undefined.
150
+ */
151
+ const generateOptionalInlineProperty = (key: string, valueExpression: string): string => {
152
+ return `...((value => value === undefined ? {} : { ${safeKey(key)}: value })(${valueExpression}))`
153
+ }
154
+
155
+ /**
156
+ * Determines if a property schema should use ref imports for validation.
157
+ */
158
+ const shouldUseRefImport = (propSchema: JSONSchema, useRefImports: boolean): boolean => {
159
+ if (!useRefImports || !hasRef(propSchema)) {
160
+ return false
161
+ }
162
+
163
+ const ref = (propSchema as { $ref: string }).$ref
164
+ // Skip external URI refs with property/definition fragments — these are not generated as files
165
+ const isUri = ref.startsWith('http://') || ref.startsWith('https://')
166
+ if (isUri && (ref.includes('#/properties/') || ref.includes('#/definitions/'))) {
167
+ return false
168
+ }
169
+
170
+ return true
171
+ }
172
+
173
+ /**
174
+ * Determines if an array property should use ref imports for its items.
175
+ */
176
+ const shouldUseArrayRefImport = (propSchema: JSONSchema, useRefImports: boolean): boolean => {
177
+ if (!useRefImports) {
178
+ return false
179
+ }
180
+ if (!isSchemaObject(propSchema)) {
181
+ return false
182
+ }
183
+ if (!('type' in propSchema) || propSchema.type !== 'array') {
184
+ return false
185
+ }
186
+ return hasItems(propSchema) && hasRef(propSchema.items)
187
+ }
188
+
189
+ /**
190
+ * Determines if a property with additionalProperties should use ref imports.
191
+ */
192
+ const shouldUseRecordRefImport = (propSchema: JSONSchema, useRefImports: boolean): boolean => {
193
+ if (!useRefImports) {
194
+ return false
195
+ }
196
+ if (!isSchemaObject(propSchema)) {
197
+ return false
198
+ }
199
+ if (!('type' in propSchema) || propSchema.type !== 'object') {
200
+ return false
201
+ }
202
+ if (!('additionalProperties' in propSchema)) {
203
+ return false
204
+ }
205
+ const additionalProps = propSchema.additionalProperties
206
+ return isSchemaObject(additionalProps) && hasRef(additionalProps)
207
+ }
208
+
209
+ /**
210
+ * Generates the value expression for a property based on its schema and options.
211
+ */
212
+ const generatePropertyValue = (
213
+ key: string,
214
+ propSchema: JSONSchema,
215
+ isRequired: boolean,
216
+ useRefImports: boolean,
217
+ ): string => {
218
+ // Handle direct $ref properties
219
+ if (shouldUseRefImport(propSchema, useRefImports)) {
220
+ const ref = (propSchema as { $ref: string }).$ref
221
+ return isRequired ? generateRequiredRefCall(key, ref) : generateOptionalRefCall(key, ref)
222
+ }
223
+
224
+ // Handle array properties with $ref items
225
+ if (shouldUseArrayRefImport(propSchema, useRefImports)) {
226
+ const items = (propSchema as { items: { $ref: string } }).items
227
+ const ref = items.$ref
228
+ return isRequired ? generateRequiredArrayRefCall(key, ref) : generateOptionalArrayRefCall(key, ref)
229
+ }
230
+
231
+ // Handle object properties with additionalProperties $ref
232
+ if (shouldUseRecordRefImport(propSchema, useRefImports)) {
233
+ const additionalProps = (propSchema as { additionalProperties: { $ref: string } }).additionalProperties
234
+ const ref = additionalProps.$ref
235
+ return isRequired ? generateRequiredRecordRefCall(key, ref) : generateOptionalRecordRefCall(key, ref)
236
+ }
237
+
238
+ // Handle non-schema object properties (true/false)
239
+ if (!isSchemaObject(propSchema)) {
240
+ return isRequired ? 'undefined' : generateOptionalInlineProperty(key, 'undefined')
241
+ }
242
+
243
+ // Generate standard validation expression
244
+ const defaultValue = getDefaultValue(propSchema)
245
+ const valueExpression = generateValidationExpression(key, propSchema, defaultValue, isRequired)
246
+ return isRequired ? valueExpression : generateOptionalInlineProperty(key, valueExpression)
247
+ }
248
+
249
+ /**
250
+ * Generates property entries for all properties in the schema.
251
+ */
252
+ const generatePropertyEntries = (schema: JSONSchema, useRefImports: boolean): PropertyEntry[] => {
253
+ if (!hasProperties(schema)) {
254
+ return []
255
+ }
256
+
257
+ const entries: PropertyEntry[] = []
258
+
259
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
260
+ const isRequired = isPropertyRequired(key, schema)
261
+ const value = generatePropertyValue(key, propSchema, isRequired, useRefImports)
262
+
263
+ // Determine if the property uses spread syntax (making it optional in the object literal)
264
+ const isOptional = value.startsWith('...')
265
+
266
+ entries.push({ key, value, isOptional })
267
+ }
268
+
269
+ return entries
270
+ }
271
+
272
+ /**
273
+ * Generates a fallback value for a required property.
274
+ * This returns simple type-based defaults without pattern inference,
275
+ * matching TypeBox's coercion behavior.
276
+ */
277
+ const generateFallbackValue = (_: string, propSchema: JSONSchema, useRefImports: boolean): string => {
278
+ // Handle direct $ref properties - call the parser with undefined
279
+ if (useRefImports && hasRef(propSchema)) {
280
+ const ref = (propSchema as { $ref: string }).$ref
281
+ const parserName = generateParserName(refToName(ref))
282
+ return `${parserName}(undefined)`
283
+ }
284
+
285
+ // Handle array properties with $ref items
286
+ if (
287
+ useRefImports &&
288
+ isSchemaObject(propSchema) &&
289
+ propSchema.type === 'array' &&
290
+ hasItems(propSchema) &&
291
+ hasRef(propSchema.items)
292
+ ) {
293
+ return '[]'
294
+ }
295
+
296
+ // Handle object properties with additionalProperties $ref
297
+ if (
298
+ useRefImports &&
299
+ isSchemaObject(propSchema) &&
300
+ propSchema.type === 'object' &&
301
+ 'additionalProperties' in propSchema
302
+ ) {
303
+ const additionalProps = propSchema.additionalProperties
304
+ if (isSchemaObject(additionalProps) && hasRef(additionalProps)) {
305
+ return '{}'
306
+ }
307
+ }
308
+
309
+ // For non-schema objects, return undefined
310
+ if (!isSchemaObject(propSchema)) {
311
+ return 'undefined'
312
+ }
313
+
314
+ // Use explicit default if provided
315
+ if (hasDefault(propSchema)) {
316
+ return JSON.stringify(propSchema.default)
317
+ }
318
+
319
+ // Use first enum value if available
320
+ if (hasEnum(propSchema) && propSchema.enum.length > 0) {
321
+ return JSON.stringify(propSchema.enum[0])
322
+ }
323
+
324
+ // Use first example if available
325
+ if (hasExamples(propSchema) && propSchema.examples.length > 0) {
326
+ return JSON.stringify(propSchema.examples[0])
327
+ }
328
+
329
+ // Return simple type-based defaults without pattern inference
330
+ if (!hasType(propSchema)) {
331
+ return 'undefined'
332
+ }
333
+
334
+ switch (propSchema.type) {
335
+ case 'string':
336
+ return '""'
337
+ case 'number':
338
+ case 'integer':
339
+ return '0'
340
+ case 'boolean':
341
+ return 'false'
342
+ case 'array':
343
+ return '[]'
344
+ case 'object':
345
+ return '{}'
346
+ default:
347
+ return 'undefined'
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Generates a fallback object with required properties filled with default values.
353
+ * This is used when input is not an object (undefined, null, etc.).
354
+ */
355
+ const generateFallbackObject = (schema: JSONSchema, useRefImports: boolean, typeName: string): string => {
356
+ if (!hasProperties(schema)) {
357
+ return `{} as ${typeName}`
358
+ }
359
+
360
+ // For schemas with conditional branches (if/then/else), the merged properties
361
+ // may not fully represent all required fields. Use a simple cast to avoid
362
+ // generating incomplete fallback objects.
363
+ if (isSchemaObject(schema) && ('if' in schema || 'then' in schema || 'else' in schema)) {
364
+ return `{} as ${typeName}`
365
+ }
366
+
367
+ const requiredProps: string[] = []
368
+
369
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
370
+ const isRequired = isPropertyRequired(key, schema)
371
+ if (isRequired) {
372
+ const fallbackValue = generateFallbackValue(key, propSchema, useRefImports)
373
+ if (fallbackValue === 'undefined') {
374
+ return `{} as ${typeName}`
375
+ }
376
+ requiredProps.push(` ${safeKey(key)}: ${fallbackValue},`)
377
+ }
378
+ }
379
+
380
+ if (requiredProps.length === 0) {
381
+ return `{} as ${typeName}`
382
+ }
383
+
384
+ let result = '{\n'
385
+ for (let i = 0; i < requiredProps.length; i++) {
386
+ result += requiredProps[i]
387
+ if (i < requiredProps.length - 1) {
388
+ result += '\n'
389
+ }
390
+ }
391
+ result += '\n }'
392
+ return result
393
+ }
394
+
395
+ /**
396
+ * Generates a parser for non-object schemas (string, number, boolean, array, etc.)
397
+ * that validates the input matches the expected primitive type before casting.
398
+ */
399
+ const generateNonObjectParser = (typeName: string, schema: JSONSchema, strict?: boolean): string => {
400
+ const functionName = generateParserName(typeName)
401
+
402
+ if (!isSchemaObject(schema) || !hasType(schema)) {
403
+ // Schema without type information cannot be validated beyond a cast
404
+ return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`
405
+ }
406
+
407
+ if (strict) {
408
+ const assertion = generateScalarStrictAssertion(schema, typeName)
409
+ if (assertion === null) {
410
+ return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`
411
+ }
412
+ const returnExpr =
413
+ schema.type === 'array' ? `[...(input as readonly unknown[])] as ${typeName}` : `input as ${typeName}`
414
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n${assertion}\n return ${returnExpr};\n};`
415
+ }
416
+
417
+ switch (schema.type) {
418
+ case 'string':
419
+ return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "string" ? input as ${typeName} : "" as ${typeName};`
420
+ case 'number':
421
+ case 'integer':
422
+ return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" ? input as ${typeName} : 0 as ${typeName};`
423
+ case 'boolean':
424
+ return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "boolean" ? input as ${typeName} : false as ${typeName};`
425
+ case 'array':
426
+ return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? [...input] as ${typeName} : [] as ${typeName};`
427
+ default:
428
+ return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Generates a parser for empty object schemas or schemas with only additionalProperties.
434
+ * Validates the input is an object before casting, falling back to an empty object.
435
+ * Returns a shallow copy to avoid mutating the original input.
436
+ */
437
+ const generateEmptyObjectParser = (typeName: string, strict?: boolean): string => {
438
+ const functionName = generateParserName(typeName)
439
+ if (strict) {
440
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);\n return { ...input } as ${typeName};\n};`
441
+ }
442
+ return `export const ${functionName} = (input: unknown): ${typeName} => isObject(input) ? { ...input } as ${typeName} : {} as ${typeName};`
443
+ }
444
+
445
+ /**
446
+ * Converts a property key to a safe local variable name for caching.
447
+ * Replaces non-identifier characters with underscores.
448
+ */
449
+ const toVarName = (key: string): string => {
450
+ const safe = key.replace(/[^a-zA-Z0-9_$]/g, '_')
451
+ return `_${safe}`
452
+ }
453
+
454
+ /**
455
+ * Returns the shape-predicate function name for a given type name.
456
+ * The predicate is generated alongside each parser and tells callers
457
+ * whether `input` is already in the shape produced by the parser's fast path.
458
+ */
459
+ const shapeValidatorName = (typeName: string): string => `validate${typeName}Shape`
460
+
461
+ /**
462
+ * Generates a fast-path type check expression for a property.
463
+ * Returns null if the schema is too complex for a simple type check
464
+ * (e.g. has unions, enums that require more elaborate validation).
465
+ *
466
+ * When `useRefImports` is true, $ref properties (and arrays of $refs) are
467
+ * checked by calling the imported shape predicate, allowing parent parsers
468
+ * to fast-path through nested ref types without recursing into their parsers.
469
+ */
470
+ const generatePropertyTypeCheck = (varName: string, schema: JSONSchema, useRefImports: boolean): string | null => {
471
+ if (!isSchemaObject(schema)) return null
472
+
473
+ // $ref via shape predicate (deep fast-path through nested types).
474
+ if (useRefImports && hasRef(schema)) {
475
+ const refName = refToName((schema as { $ref: string }).$ref)
476
+ return `${shapeValidatorName(refName)}(${varName})`
477
+ }
478
+
479
+ // Skip fast path for complex schemas that need more elaborate validation
480
+ if (
481
+ hasEnum(schema) ||
482
+ hasOneOf(schema) ||
483
+ hasAnyOf(schema) ||
484
+ hasAllOf(schema) ||
485
+ hasRef(schema) ||
486
+ 'not' in schema
487
+ ) {
488
+ return null
489
+ }
490
+
491
+ if (!hasType(schema)) return null
492
+
493
+ // Array of $refs: each item must satisfy the imported shape predicate.
494
+ if (
495
+ useRefImports &&
496
+ schema.type === 'array' &&
497
+ hasItems(schema) &&
498
+ isSchemaObject(schema.items) &&
499
+ hasRef(schema.items)
500
+ ) {
501
+ const refName = refToName((schema.items as { $ref: string }).$ref)
502
+ return `Array.isArray(${varName}) && ${varName}.every(${shapeValidatorName(refName)})`
503
+ }
504
+
505
+ const checks: string[] = []
506
+
507
+ switch (schema.type) {
508
+ case 'string': {
509
+ checks.push(`typeof ${varName} === "string"`)
510
+ if (hasPattern(schema)) checks.push(`/${escapeRegexPattern(schema.pattern)}/.test(${varName})`)
511
+ if (hasMinLength(schema)) checks.push(`${varName}.length >= ${schema.minLength}`)
512
+ if (hasMaxLength(schema)) checks.push(`${varName}.length <= ${schema.maxLength}`)
513
+ break
514
+ }
515
+ case 'number':
516
+ case 'integer': {
517
+ checks.push(`typeof ${varName} === "number"`)
518
+ if (hasMinimum(schema)) checks.push(`${varName} >= ${schema.minimum}`)
519
+ if (hasMaximum(schema)) checks.push(`${varName} <= ${schema.maximum}`)
520
+ if (hasExclusiveMinimum(schema)) checks.push(`${varName} > ${schema.exclusiveMinimum}`)
521
+ if (hasExclusiveMaximum(schema)) checks.push(`${varName} < ${schema.exclusiveMaximum}`)
522
+ if (hasMultipleOf(schema)) checks.push(`${varName} % ${schema.multipleOf} === 0`)
523
+ break
524
+ }
525
+ case 'boolean':
526
+ checks.push(`typeof ${varName} === "boolean"`)
527
+ break
528
+ case 'array': {
529
+ checks.push(`Array.isArray(${varName})`)
530
+ if (hasMinItems(schema)) checks.push(`${varName}.length >= ${schema.minItems}`)
531
+ if (hasMaxItems(schema)) checks.push(`${varName}.length <= ${schema.maxItems}`)
532
+ if (hasUniqueItems(schema) && schema.uniqueItems === true) {
533
+ checks.push(`new Set(${varName}).size === ${varName}.length`)
534
+ }
535
+ break
536
+ }
537
+ case 'object':
538
+ checks.push(`isObject(${varName})`)
539
+ break
540
+ default:
541
+ return null
542
+ }
543
+
544
+ if (checks.length === 0) return null
545
+ // checks[0] is safe: we guard with checks.length === 0 above
546
+ let result = checks[0] as string
547
+ for (let i = 1; i < checks.length; i++) {
548
+ result += ' && ' + checks[i]
549
+ }
550
+ return result
551
+ }
552
+
553
+ /**
554
+ * Determines if a property needs a local variable or can be inlined.
555
+ * Schema-object properties are always cached: the slow-path ternary chain
556
+ * reads each value 3-4x (typeof check, valid branch, undefined check, coerce),
557
+ * so a single hoisted load is strictly cheaper than the inlined optional chain.
558
+ */
559
+ const shouldCacheVariable = (propSchema: JSONSchema, _canFastPath: boolean, _useRefImports: boolean): boolean => {
560
+ // Non-schema-object properties (true/false JSON Schema literals) generate
561
+ // `undefined` with no value access, so caching would be wasted.
562
+ return isSchemaObject(propSchema)
563
+ }
564
+
565
+ /**
566
+ * Generates a parser for object schemas with properties.
567
+ *
568
+ * Uses an optimized function body with:
569
+ * - Early return for non-object input
570
+ * - Selective variable caching (only when needed)
571
+ * - Fast path that returns input directly when all properties are already valid
572
+ * - Slow path with optimized expressions
573
+ */
574
+ const generateObjectParser = (
575
+ schema: JSONSchema,
576
+ typeName: string,
577
+ useRefImports: boolean,
578
+ logWarnings?: boolean,
579
+ strict?: boolean,
580
+ ): string => {
581
+ const functionName = generateParserName(typeName)
582
+
583
+ if (!hasProperties(schema)) {
584
+ if (strict) {
585
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);\n return input as ${typeName};\n};`
586
+ }
587
+ return `export const ${functionName} = (input: unknown): ${typeName} => isObject(input) ? input as ${typeName} : {} as ${typeName};`
588
+ }
589
+
590
+ const fallbackObject = generateFallbackObject(schema, useRefImports, typeName)
591
+ const properties = Object.entries((schema as { properties: Record<string, JSONSchema> }).properties)
592
+
593
+ const lines: string[] = []
594
+ lines.push(`export const ${functionName} = (input: unknown): ${typeName} => {`)
595
+ if (strict) {
596
+ for (const assertionLine of generateObjectStrictAssertion(schema, typeName)) {
597
+ lines.push(assertionLine)
598
+ }
599
+ } else {
600
+ lines.push(` if (!isObject(input)) return ${fallbackObject};`)
601
+ }
602
+
603
+ // First pass: determine if we can generate a fast path
604
+ const propInfo: {
605
+ readonly key: string
606
+ readonly varName: string
607
+ readonly isRequired: boolean
608
+ readonly propSchema: JSONSchema
609
+ }[] = []
610
+
611
+ // Disable fast path when allOf $ref parsers need to be spread — the fast path
612
+ // returns input as-is and would skip those coercions.
613
+ const hasAllOfRefParsers =
614
+ useRefImports &&
615
+ isSchemaObject(schema) &&
616
+ hasAllOf(schema) &&
617
+ schema.allOf.some((entry) => isSchemaObject(entry) && hasRef(entry))
618
+
619
+ let canFastPath = !hasAllOfRefParsers
620
+ const fastPathChecks: string[] = []
621
+
622
+ for (const [key, propSchema] of properties) {
623
+ const isRequired = isPropertyRequired(key, schema)
624
+ const varName = toVarName(key)
625
+ propInfo.push({ key, varName, isRequired, propSchema })
626
+
627
+ // Check if this property can participate in fast path
628
+ if (canFastPath) {
629
+ // Records of refs require iterating every value to check shape — too
630
+ // expensive to inline into the parser's fast path. Disable.
631
+ if (shouldUseRecordRefImport(propSchema, useRefImports)) {
632
+ canFastPath = false
633
+ } else {
634
+ const check = generatePropertyTypeCheck(varName, propSchema, useRefImports)
635
+ if (check === null) {
636
+ canFastPath = false
637
+ } else {
638
+ if (isRequired) {
639
+ fastPathChecks.push(check)
640
+ } else {
641
+ fastPathChecks.push(`(${varName} === undefined || ${check})`)
642
+ }
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ // Second pass: generate variable declarations only for properties that need them
649
+ for (const { key, varName, propSchema } of propInfo) {
650
+ if (shouldCacheVariable(propSchema, canFastPath, useRefImports)) {
651
+ lines.push(` const ${varName} = ${safeAccessor('input', key)};`)
652
+ }
653
+ }
654
+
655
+ // Emit a warning for any input key not declared in the schema's properties
656
+ if (logWarnings && propInfo.length > 0) {
657
+ const keysList = propInfo.map(({ key }) => JSON.stringify(key)).join(', ')
658
+ lines.push(` const _knownKeys = new Set([${keysList}]);`)
659
+ lines.push(` for (const _k in input) {`)
660
+ lines.push(` if (!_knownKeys.has(_k)) {`)
661
+ lines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`)
662
+ lines.push(` }`)
663
+ lines.push(` }`)
664
+ }
665
+
666
+ // Generate fast-path check if possible
667
+ if (canFastPath && fastPathChecks.length > 0) {
668
+ let fastPathExpr = fastPathChecks[0]
669
+ for (let i = 1; i < fastPathChecks.length; i++) {
670
+ fastPathExpr += ' && ' + fastPathChecks[i]
671
+ }
672
+ lines.push(` if (${fastPathExpr}) return { ...input } as ${typeName};`)
673
+ }
674
+
675
+ // Generate slow-path object construction
676
+ const objectLines: string[] = []
677
+ objectLines.push(' ...input,')
678
+
679
+ // Spread allOf $ref parsers so their field coercions are applied before
680
+ // the explicit property validations below.
681
+ if (useRefImports && isSchemaObject(schema) && hasAllOf(schema)) {
682
+ for (const entry of schema.allOf) {
683
+ if (isSchemaObject(entry) && hasRef(entry)) {
684
+ const parserName = generateParserName(refToName(entry.$ref))
685
+ objectLines.push(` ...(${parserName}(input) as Record<string, unknown>),`)
686
+ }
687
+ }
688
+ }
689
+
690
+ for (const { key, varName, isRequired, propSchema } of propInfo) {
691
+ const shouldCache = shouldCacheVariable(propSchema, canFastPath, useRefImports)
692
+ const accessor = shouldCache ? varName : safeAccessor('input', key)
693
+
694
+ // Handle direct $ref properties via imported parsers
695
+ if (shouldUseRefImport(propSchema, useRefImports)) {
696
+ const ref = (propSchema as { $ref: string }).$ref
697
+ const parserName = generateParserName(refToName(ref))
698
+ if (isRequired) {
699
+ objectLines.push(` ${safeKey(key)}: ${parserName}(${accessor}),`)
700
+ } else {
701
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${parserName}(${accessor}) }),`)
702
+ }
703
+ continue
704
+ }
705
+
706
+ // Handle array properties with $ref items
707
+ if (shouldUseArrayRefImport(propSchema, useRefImports)) {
708
+ const items = (propSchema as { items: { $ref: string } }).items
709
+ const ref = items.$ref
710
+ const parserName = generateParserName(refToName(ref))
711
+ if (isRequired) {
712
+ objectLines.push(` ${safeKey(key)}: validateArray(${accessor}, ${parserName}),`)
713
+ } else {
714
+ objectLines.push(
715
+ ` ...(${accessor} !== undefined && { ${safeKey(key)}: validateArray(${accessor}, ${parserName}) }),`,
716
+ )
717
+ }
718
+ continue
719
+ }
720
+
721
+ // Handle object properties with additionalProperties $ref
722
+ if (shouldUseRecordRefImport(propSchema, useRefImports)) {
723
+ const additionalProps = (propSchema as { additionalProperties: { $ref: string } }).additionalProperties
724
+ const ref = additionalProps.$ref
725
+ const parserName = generateParserName(refToName(ref))
726
+ if (isRequired) {
727
+ objectLines.push(` ${safeKey(key)}: validateRecord(${accessor}, ${parserName}),`)
728
+ } else {
729
+ objectLines.push(
730
+ ` ...(${accessor} !== undefined && { ${safeKey(key)}: validateRecord(${accessor}, ${parserName}) }),`,
731
+ )
732
+ }
733
+ continue
734
+ }
735
+
736
+ // Handle non-schema-object properties
737
+ if (!isSchemaObject(propSchema)) {
738
+ if (isRequired) {
739
+ objectLines.push(` ${safeKey(key)}: undefined,`)
740
+ }
741
+ continue
742
+ }
743
+
744
+ // Generate validation expression
745
+ const defaultValue = getDefaultValue(propSchema)
746
+ // For optional properties in the slow path, we know the value is not undefined
747
+ // because we're inside the `...(accessor !== undefined && { ... })` check
748
+ const knownNotUndefined = !isRequired
749
+ const valueExpr = generateValidationExpression(
750
+ key,
751
+ propSchema,
752
+ defaultValue,
753
+ true,
754
+ undefined,
755
+ undefined,
756
+ shouldCache ? varName : undefined,
757
+ knownNotUndefined,
758
+ )
759
+
760
+ if (isRequired) {
761
+ objectLines.push(` ${safeKey(key)}: ${valueExpr},`)
762
+ } else {
763
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${valueExpr} }),`)
764
+ }
765
+ }
766
+
767
+ lines.push(` return {`)
768
+ // objectLines[0] is safe: objectLines is always non-empty (has at least the closing brace line)
769
+ let objectBody = objectLines[0] as string
770
+ for (let i = 1; i < objectLines.length; i++) {
771
+ objectBody += '\n' + objectLines[i]
772
+ }
773
+ lines.push(objectBody)
774
+ lines.push(` } as unknown as ${typeName};`)
775
+ lines.push(`}`)
776
+
777
+ // lines[0] is safe: lines is always non-empty (it always has the function declaration)
778
+ let result = lines[0] as string
779
+ for (let i = 1; i < lines.length; i++) {
780
+ result += '\n' + lines[i]
781
+ }
782
+ return result
783
+ }
784
+
785
+ /**
786
+ * Generates a parser for schemas that have both properties AND patternProperties.
787
+ * Parses the known properties first, then iterates remaining keys to match patterns.
788
+ */
789
+ const generateCombinedObjectParser = (
790
+ schema: JSONSchema,
791
+ typeName: string,
792
+ useRefImports: boolean,
793
+ logWarnings?: boolean,
794
+ strict?: boolean,
795
+ ): string => {
796
+ const functionName = generateParserName(typeName)
797
+ const entries = generatePropertyEntries(schema, useRefImports)
798
+
799
+ // Build the known property lines for the initial object
800
+ const propertyLines = entries.map((entry) => {
801
+ if (entry.isOptional) {
802
+ return ` ${entry.value},`
803
+ }
804
+ return ` ${safeKey(entry.key)}: ${entry.value},`
805
+ })
806
+
807
+ // Find the first pattern with a $ref for parser delegation
808
+ if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
809
+ return generateObjectParser(schema, typeName, useRefImports, logWarnings, strict)
810
+ }
811
+
812
+ const patternProps = schema.patternProperties as Record<string, JSONSchema>
813
+ const patterns = Object.entries(patternProps)
814
+ const refPattern = patterns.find(([, ps]) => isSchemaObject(ps) && hasRef(ps))
815
+
816
+ if (!refPattern || !useRefImports) {
817
+ return generateObjectParser(schema, typeName, useRefImports, logWarnings, strict)
818
+ }
819
+
820
+ const [pattern, patternSchema] = refPattern
821
+ const ref = (patternSchema as { $ref: string }).$ref
822
+ const parserName = generateParserName(refToName(ref))
823
+ const assignmentCode = `(result as Record<string, unknown>)[key] = ${parserName}(value);`
824
+
825
+ const escapedPattern = escapeRegexPattern(pattern)
826
+
827
+ const inputSpread = ' ...input,'
828
+ let objectProperties = inputSpread
829
+ if (propertyLines.length > 0) {
830
+ for (const line of propertyLines) {
831
+ objectProperties += '\n' + line
832
+ }
833
+ }
834
+
835
+ const notObjectBranch = strict
836
+ ? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
837
+ : ` return {} as unknown as ${typeName};`
838
+
839
+ return `export const ${functionName} = (input: unknown): ${typeName} => {
840
+ if (!isObject(input)) {
841
+ ${notObjectBranch}
842
+ }
843
+ const result = {
844
+ ${objectProperties}
845
+ } as unknown as ${typeName};
846
+ for (const key in input) {
847
+ if (/${escapedPattern}/.test(key)) {
848
+ const value = input[key];
849
+ ${assignmentCode}
850
+ }
851
+ }
852
+ return result;
853
+ };`
854
+ }
855
+
856
+ /**
857
+ * Generates a parser for schemas with additionalProperties.
858
+ */
859
+ const generateAdditionalPropertiesParser = (
860
+ schema: JSONSchema.Object,
861
+ typeName: string,
862
+ useRefImports: boolean,
863
+ strict?: boolean,
864
+ ): string => {
865
+ const functionName = generateParserName(typeName)
866
+ const additionalProps = schema.additionalProperties
867
+
868
+ // Check if additionalProps is defined before using it
869
+ if (!additionalProps) {
870
+ return generateEmptyObjectParser(typeName, strict)
871
+ }
872
+
873
+ // If additionalProperties is a $ref and useRefImports is true, generate a loop
874
+ if (useRefImports && isSchemaObject(additionalProps) && hasRef(additionalProps)) {
875
+ const ref = additionalProps.$ref
876
+ const parserName = generateParserName(refToName(ref))
877
+
878
+ if (strict) {
879
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);\n return validateRecord(input, ${parserName}) as ${typeName};\n};`
880
+ }
881
+ return `export const ${functionName} = (input: unknown): ${typeName} => validateRecord(input, ${parserName}) as ${typeName};`
882
+ }
883
+
884
+ // Handle additionalProperties with a known type by generating inline validation
885
+ if (isSchemaObject(additionalProps) && hasType(additionalProps)) {
886
+ const inlineParser = generateInlineValueParser(additionalProps)
887
+ if (inlineParser) {
888
+ if (strict) {
889
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);\n return validateRecord(input, ${inlineParser}) as ${typeName};\n};`
890
+ }
891
+ return `export const ${functionName} = (input: unknown): ${typeName} => validateRecord(input, ${inlineParser}) as ${typeName};`
892
+ }
893
+ }
894
+
895
+ // Otherwise, just validate the input type and shallow copy
896
+ if (strict) {
897
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);\n return { ...input } as ${typeName};\n};`
898
+ }
899
+ return `export const ${functionName} = (input: unknown): ${typeName} => isObject(input) ? { ...input } as ${typeName} : {};`
900
+ }
901
+
902
+ /**
903
+ * Generates an inline arrow function that validates a single value based on
904
+ * the additionalProperties schema type. Returns null if the type is not supported.
905
+ *
906
+ * Used to generate parsers for Record-like types where values have a known
907
+ * primitive or array type (e.g. Record<string, string> or Record<string, string[]>).
908
+ */
909
+ const generateInlineValueParser = (schema: JSONSchema): string | null => {
910
+ if (!isSchemaObject(schema) || !hasType(schema)) {
911
+ return null
912
+ }
913
+
914
+ switch (schema.type) {
915
+ case 'string':
916
+ return '(value: unknown) => typeof value === "string" ? value : ""'
917
+ case 'number':
918
+ case 'integer':
919
+ return '(value: unknown) => typeof value === "number" ? value : 0'
920
+ case 'boolean':
921
+ return '(value: unknown) => typeof value === "boolean" ? value : false'
922
+ case 'array':
923
+ return '(value: unknown) => Array.isArray(value) ? value : []'
924
+ default:
925
+ return null
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Escapes special regex characters in a pattern string for use in a regex literal.
931
+ * This escapes backslashes and forward slashes for safe inclusion in /pattern/ syntax.
932
+ */
933
+ const escapeRegexPattern = (pattern: string): string => {
934
+ // Escape backslashes first, then forward slashes
935
+ return pattern.replace(/\\/g, '\\\\').replace(/\//g, '\\/')
936
+ }
937
+
938
+ /**
939
+ * Generates a parser for schemas with patternProperties.
940
+ * Handles both patternProperties and specification-extensions (x- prefix).
941
+ */
942
+ const generatePatternPropertiesParser = (
943
+ schema: JSONSchema.Object,
944
+ typeName: string,
945
+ useRefImports: boolean,
946
+ strict?: boolean,
947
+ ): string => {
948
+ const functionName = generateParserName(typeName)
949
+
950
+ if (!('patternProperties' in schema) || typeof schema.patternProperties !== 'object') {
951
+ return generateEmptyObjectParser(typeName, strict)
952
+ }
953
+
954
+ const patternProps = schema.patternProperties as Record<string, JSONSchema>
955
+
956
+ // Find the first pattern and its schema
957
+ const patterns = Object.entries(patternProps)
958
+ if (patterns.length === 0) {
959
+ return generateEmptyObjectParser(typeName, strict)
960
+ }
961
+
962
+ const [pattern, patternSchema] = patterns[0] as [string, JSONSchema]
963
+ let patternAssignment = '(result as Record<string, unknown>)[key] = value;'
964
+
965
+ // Use imported parser when pattern schema points to a $ref.
966
+ if (useRefImports && isSchemaObject(patternSchema) && hasRef(patternSchema)) {
967
+ const ref = patternSchema.$ref
968
+ const parserName = generateParserName(refToName(ref))
969
+ patternAssignment = `(result as Record<string, unknown>)[key] = ${parserName}(value);`
970
+ } else if (patternSchema === false) {
971
+ // `false` means no values are allowed for matching keys.
972
+ patternAssignment = ''
973
+ }
974
+
975
+ // Escape the pattern for safe inclusion in generated code
976
+ const escapedPattern = escapeRegexPattern(pattern)
977
+
978
+ const notObjectBranch = strict
979
+ ? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
980
+ : ` return {} as unknown as ${typeName};`
981
+
982
+ // Generate a parser that handles both pattern matching and x- extensions
983
+ return `export const ${functionName} = (input: unknown): ${typeName} => {
984
+ if (!isObject(input)) {
985
+ ${notObjectBranch}
986
+ }
987
+ const result = {
988
+ ...input,
989
+ } as unknown as ${typeName};
990
+ for (const key in input) {
991
+ if (/${escapedPattern}/.test(key)) {
992
+ const value = input[key];
993
+ ${patternAssignment}
994
+ }
995
+ }
996
+ return result;
997
+ };`
998
+ }
999
+
1000
+ /**
1001
+ * Generates a parser for conditional schemas with if/then/else.
1002
+ * This always generates parser calls regardless of useRefImports setting,
1003
+ * because conditional logic requires delegating to the appropriate parser.
1004
+ */
1005
+ const generateConditionalParser = (schema: JSONSchema.Object, typeName: string): string => {
1006
+ const functionName = generateParserName(typeName)
1007
+
1008
+ // Extract the condition and branches
1009
+ const ifSchema = schema.if
1010
+ const thenSchema = schema.then
1011
+ const elseSchema = schema.else
1012
+
1013
+ // Check if branches are defined and have $ref
1014
+ const thenHasRef = thenSchema && isSchemaObject(thenSchema) && hasRef(thenSchema)
1015
+ const elseHasRef = elseSchema && isSchemaObject(elseSchema) && hasRef(elseSchema)
1016
+
1017
+ if (!thenHasRef || !elseHasRef) {
1018
+ // Non-$ref branches (e.g. if/then/else providing defaults for properties).
1019
+ // Flatten all three branches into a single object schema and generate a
1020
+ // regular object parser from the merged property set.
1021
+ const mergedSchema = getConditionalObjectSchema(schema)
1022
+ if (mergedSchema) {
1023
+ return generateObjectParser(mergedSchema, typeName, false)
1024
+ }
1025
+ return generateEmptyObjectParser(typeName)
1026
+ }
1027
+
1028
+ // Check if the condition is checking for $ref property
1029
+ const isRefCondition =
1030
+ ifSchema &&
1031
+ isSchemaObject(ifSchema) &&
1032
+ 'required' in ifSchema &&
1033
+ Array.isArray(ifSchema.required) &&
1034
+ ifSchema.required.includes('$ref')
1035
+
1036
+ if (!isRefCondition) {
1037
+ // Condition is not a $ref check — flatten into an object parser
1038
+ const mergedSchema = getConditionalObjectSchema(schema)
1039
+ if (mergedSchema) {
1040
+ return generateObjectParser(mergedSchema, typeName, false)
1041
+ }
1042
+ return generateEmptyObjectParser(typeName)
1043
+ }
1044
+
1045
+ const thenRef = thenSchema.$ref
1046
+ const elseRef = elseSchema.$ref
1047
+
1048
+ const thenParserName = generateParserName(refToName(thenRef))
1049
+ const elseParserName = generateParserName(refToName(elseRef))
1050
+ const thenTypeName = refToName(thenRef)
1051
+
1052
+ return `export const ${functionName} = (input: unknown): ${typeName} | ${thenTypeName} =>
1053
+ hasRef(input) ? ${thenParserName}(input) : ${elseParserName}(input)
1054
+ `
1055
+ }
1056
+
1057
+ /**
1058
+ * Builds an object schema from conditional if/then keywords when the schema does not
1059
+ * declare type: "object". Flattens if/then/else property sets into a single object schema.
1060
+ */
1061
+ const getConditionalObjectSchema = (schema: JSONSchema): JSONSchema.Object | null => {
1062
+ if (!isSchemaObject(schema)) {
1063
+ return null
1064
+ }
1065
+
1066
+ if (!('if' in schema) || !('then' in schema)) {
1067
+ return null
1068
+ }
1069
+
1070
+ const ifSchema = schema.if
1071
+ const thenSchema = schema.then
1072
+ const elseSchema = 'else' in schema ? schema.else : undefined
1073
+
1074
+ if (!isSchemaObject(ifSchema) || !isSchemaObject(thenSchema)) {
1075
+ return null
1076
+ }
1077
+
1078
+ const ifProperties = ifSchema.properties
1079
+ const thenProperties = thenSchema.properties
1080
+ const elseProperties = elseSchema && isSchemaObject(elseSchema) ? elseSchema.properties : undefined
1081
+ const hasIfProperties = ifProperties && typeof ifProperties === 'object'
1082
+ const hasThenProperties = thenProperties && typeof thenProperties === 'object'
1083
+ const hasElseProperties = elseProperties && typeof elseProperties === 'object'
1084
+
1085
+ if (!hasIfProperties && !hasThenProperties && !hasElseProperties) {
1086
+ return null
1087
+ }
1088
+
1089
+ const required = new Set<string>()
1090
+
1091
+ if (Array.isArray(ifSchema.required)) {
1092
+ for (const key of ifSchema.required) {
1093
+ required.add(key)
1094
+ }
1095
+ }
1096
+
1097
+ // If the `if` condition checks for a `const` value on a property, that property
1098
+ // is effectively required (the schema only applies when the condition is true).
1099
+ if (hasIfProperties && ifProperties && typeof ifProperties === 'object') {
1100
+ for (const [key, propSchema] of Object.entries(ifProperties as Record<string, JSONSchema>)) {
1101
+ if (isSchemaObject(propSchema) && hasConst(propSchema)) {
1102
+ required.add(key)
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ if (Array.isArray(thenSchema.required)) {
1108
+ for (const key of thenSchema.required) {
1109
+ required.add(key)
1110
+ }
1111
+ }
1112
+
1113
+ return {
1114
+ type: 'object',
1115
+ properties: {
1116
+ // else properties first so then properties take precedence on overlap
1117
+ ...(hasElseProperties ? elseProperties : {}),
1118
+ ...(hasIfProperties ? ifProperties : {}),
1119
+ ...(hasThenProperties ? thenProperties : {}),
1120
+ },
1121
+ ...(required.size > 0 ? { required: Array.from(required) } : {}),
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * Generates a parser for SchemaObject that validates all JSON Schema 2020-12 properties.
1127
+ * This handles the special case where a schema can be any valid JSON Schema.
1128
+ */
1129
+ const generateSchemaObjectParser = (typeName: string): string => {
1130
+ const functionName = generateParserName(typeName)
1131
+
1132
+ return `export const ${functionName} = (input: unknown): ${typeName} => {
1133
+ if (typeof input === 'boolean') {
1134
+ return input as ${typeName};
1135
+ }
1136
+
1137
+ if (!isObject(input)) {
1138
+ return {} as ${typeName};
1139
+ }
1140
+
1141
+ return input as ${typeName};
1142
+ };`
1143
+ }
1144
+
1145
+ /**
1146
+ * Determines the appropriate parser generation strategy for a schema.
1147
+ */
1148
+ const selectParserStrategy = (schema: JSONSchema, typeName: string, options?: GenerateParserOptions): string => {
1149
+ const useRefImports = options?.useRefImports ?? false
1150
+ const logWarnings = options?.logWarnings ?? false
1151
+ const strict = options?.strict ?? false
1152
+
1153
+ // Special case for SchemaObject - it can be any JSON Schema
1154
+ if (typeName === 'SchemaObject') {
1155
+ return generateSchemaObjectParser(typeName)
1156
+ }
1157
+
1158
+ const isObjectLikeSchema =
1159
+ isObjectSchema(schema) ||
1160
+ (isSchemaObject(schema) && ('patternProperties' in schema || 'additionalProperties' in schema))
1161
+
1162
+ // Handle non-object schemas with type-appropriate validation
1163
+ if (!isObjectLikeSchema && !isSchemaObject(schema)) {
1164
+ return generateNonObjectParser(typeName, schema, strict)
1165
+ }
1166
+
1167
+ // Handle schemas with both properties AND patternProperties.
1168
+ // This generates a parser that handles known properties and also iterates
1169
+ // pattern-matched keys (e.g. responses with "default" + "200", "4XX").
1170
+ if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
1171
+ return generateCombinedObjectParser(schema, typeName, useRefImports, logWarnings, strict)
1172
+ }
1173
+
1174
+ // Handle schemas that have explicit properties — generate a full object parser.
1175
+ // This intentionally runs before if/then checks so that schemas with both
1176
+ // properties AND conditional keywords use all declared properties rather than
1177
+ // only the if/then fragment.
1178
+ if (hasProperties(schema)) {
1179
+ return generateObjectParser(schema, typeName, useRefImports, logWarnings, strict)
1180
+ }
1181
+
1182
+ // Handle conditional schemas (if/then/else) for schemas without explicit properties.
1183
+ if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
1184
+ return generateConditionalParser(schema as JSONSchema.Object, typeName)
1185
+ }
1186
+
1187
+ // Handle conditional schemas that only define if/then object fragments.
1188
+ // We flatten the fragments into a regular object parser.
1189
+ const conditionalObjectSchema = getConditionalObjectSchema(schema)
1190
+ if (conditionalObjectSchema) {
1191
+ return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, logWarnings, strict)
1192
+ }
1193
+
1194
+ // Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
1195
+ if (!isObjectLikeSchema) {
1196
+ return generateNonObjectParser(typeName, schema, strict)
1197
+ }
1198
+
1199
+ // Handle schemas with patternProperties (but no properties)
1200
+ if ('patternProperties' in schema) {
1201
+ return generatePatternPropertiesParser(schema as JSONSchema.Object, typeName, useRefImports, strict)
1202
+ }
1203
+
1204
+ // Handle schemas with additionalProperties as true or false (but no properties)
1205
+ if ('additionalProperties' in schema && !hasProperties(schema)) {
1206
+ const additionalProps = schema.additionalProperties
1207
+
1208
+ // If additionalProperties is true or false, validate it is an object
1209
+ if (additionalProps === true || additionalProps === false) {
1210
+ return generateEmptyObjectParser(typeName, strict)
1211
+ }
1212
+
1213
+ // Otherwise, handle as a schema (could be a $ref or object schema)
1214
+ return generateAdditionalPropertiesParser(schema as JSONSchema.Object, typeName, useRefImports, strict)
1215
+ }
1216
+
1217
+ // Handle empty object schemas
1218
+ if ('type' in schema && schema.type === 'object' && !hasProperties(schema)) {
1219
+ return generateEmptyObjectParser(typeName, strict)
1220
+ }
1221
+
1222
+ // Default fallback - validate it is an object since we passed the isObjectSchema check
1223
+ return generateEmptyObjectParser(typeName, strict)
1224
+ }
1225
+
1226
+ /**
1227
+ * Generates a safe parser as an arrow function that never throws.
1228
+ * The parser provides default values for all missing or invalid fields.
1229
+ *
1230
+ * When useRefImports is enabled, properties with $ref will call the imported
1231
+ * parser function (e.g., parseContact(input?.contact)) instead of inlining
1232
+ * the resolved schema's validation logic. Array properties whose items are a
1233
+ * $ref will map each element through the imported parser.
1234
+ */
1235
+ export const generateParserFunction = (
1236
+ schema: JSONSchema,
1237
+ typeName: string,
1238
+ options?: GenerateParserOptions,
1239
+ ): string => {
1240
+ return selectParserStrategy(schema, typeName, options)
1241
+ }
1242
+
1243
+ /**
1244
+ * Returns the predicate function name for a generated shape validator.
1245
+ * @example shapeValidatorFunctionName('CustomerObject') // 'validateCustomerObjectShape'
1246
+ */
1247
+ export const shapeValidatorFunctionName = (typeName: string): string => shapeValidatorName(typeName)
1248
+
1249
+ /**
1250
+ * Generates a `validate{TypeName}Shape(input)` predicate that returns true
1251
+ * iff `input` already matches the shape produced by the parser's fast path —
1252
+ * i.e. the parser would return `{ ...input } as TypeName` without coercion.
1253
+ *
1254
+ * Parents call this predicate to fast-path through nested ref properties
1255
+ * (and arrays of refs) without recursing into their parser.
1256
+ *
1257
+ * Returns a stub that always returns `false` for schemas that cannot be
1258
+ * predicated (composition, conditionals, pattern properties, complex refs
1259
+ * in additionalProperties). The stub is safe: parents calling it will fall
1260
+ * through to the slow path, matching the pre-deep-fast-path behavior.
1261
+ */
1262
+ export const generateShapeValidator = (schema: JSONSchema, typeName: string, useRefImports: boolean): string => {
1263
+ const fnName = shapeValidatorName(typeName)
1264
+ const stub = `export const ${fnName} = (_input: unknown): boolean => false;`
1265
+
1266
+ if (!isSchemaObject(schema)) return stub
1267
+ if (!hasProperties(schema)) return stub
1268
+
1269
+ // Composition / conditional schemas can match in many shapes — bail.
1270
+ if (
1271
+ hasOneOf(schema) ||
1272
+ hasAnyOf(schema) ||
1273
+ hasAllOf(schema) ||
1274
+ 'not' in schema ||
1275
+ 'patternProperties' in schema ||
1276
+ 'if' in schema ||
1277
+ 'then' in schema ||
1278
+ 'else' in schema
1279
+ ) {
1280
+ return stub
1281
+ }
1282
+
1283
+ const properties = Object.entries((schema as { properties: Record<string, JSONSchema> }).properties)
1284
+ const checks: string[] = []
1285
+
1286
+ for (const [key, propSchema] of properties) {
1287
+ // Records of refs require iterating every value — too expensive for the fast path.
1288
+ if (shouldUseRecordRefImport(propSchema, useRefImports)) {
1289
+ return stub
1290
+ }
1291
+
1292
+ const accessor = safeAccessor('input', key)
1293
+ const isRequired = isPropertyRequired(key, schema)
1294
+ const check = generatePropertyTypeCheck(accessor, propSchema, useRefImports)
1295
+ if (check === null) {
1296
+ return stub
1297
+ }
1298
+
1299
+ if (isRequired) {
1300
+ checks.push(check)
1301
+ } else {
1302
+ checks.push(`(${accessor} === undefined || ${check})`)
1303
+ }
1304
+ }
1305
+
1306
+ if (checks.length === 0) {
1307
+ return `export const ${fnName} = (input: unknown): boolean => isObject(input);`
1308
+ }
1309
+
1310
+ let body = checks[0] as string
1311
+ for (let i = 1; i < checks.length; i++) {
1312
+ body += '\n && ' + checks[i]
1313
+ }
1314
+
1315
+ return `export const ${fnName} = (input: unknown): boolean => {
1316
+ if (!isObject(input)) return false;
1317
+ return ${body};
1318
+ };`
1319
+ }