@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,280 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, resolve as resolvePath } from 'node:path'
4
+ import { buildDynamicRefMap } from '@amritk/helpers/build-dynamic-ref-map'
5
+ import { extractRefs } from '@amritk/helpers/extract-refs'
6
+ import { refToFilename } from '@amritk/helpers/ref-to-filename'
7
+ import { refToName } from '@amritk/helpers/ref-to-name'
8
+ import { resolveDynamicRefs } from '@amritk/helpers/resolve-dynamic-refs'
9
+ import { resolveRef } from '@amritk/helpers/resolve-ref'
10
+ import { upgradeDraft07Schema } from '@amritk/helpers/upgrade-draft07-schema'
11
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
12
+ import { applySchemaExtensions } from '#helpers/apply-schema-extensions'
13
+ import type { HelpersMode, RuntimeHelperName } from '#helpers/collect-helpers'
14
+ import type { SchemaExtensions } from '#types/schema-extensions'
15
+
16
+ import { generateFile } from './generate-files'
17
+
18
+ /** Locate the @amritk/helpers package on disk so we can copy its runtime
19
+ * helper source files into the generated output when in embedded mode. */
20
+ const readHelperSource = async (helper: RuntimeHelperName): Promise<string> => {
21
+ const require = createRequire(import.meta.url)
22
+ const helpersPkgPath = require.resolve('@amritk/helpers/package.json')
23
+ const helpersRoot = dirname(helpersPkgPath)
24
+ return readFile(resolvePath(helpersRoot, 'src', `${helper}.ts`), 'utf-8')
25
+ }
26
+
27
+ /**
28
+ * Represents a generated file with its filename and content.
29
+ */
30
+ export type GeneratedFile = {
31
+ filename: string
32
+ content: string
33
+ }
34
+
35
+ /**
36
+ * Extracts all definitions from $defs that have a $dynamicAnchor.
37
+ * These definitions need to be generated even if not directly referenced by $ref.
38
+ */
39
+ const extractDynamicAnchorDefs = (schema: JSONSchema): string[] => {
40
+ const refs: string[] = []
41
+
42
+ if (typeof schema !== 'object' || schema === null) {
43
+ return refs
44
+ }
45
+
46
+ // Check if schema has $defs
47
+ if (!('$defs' in schema) || typeof schema['$defs'] !== 'object' || schema['$defs'] === null) {
48
+ return refs
49
+ }
50
+
51
+ const defs = schema['$defs']
52
+
53
+ // Find all definitions with $dynamicAnchor
54
+ for (const [key, value] of Object.entries(defs)) {
55
+ if (typeof value === 'object' && value !== null) {
56
+ const defSchema = value as Record<string, unknown>
57
+ if ('$dynamicAnchor' in defSchema) {
58
+ // Convert to a $ref-style path
59
+ refs.push(`#/$defs/${key}`)
60
+ }
61
+ }
62
+ }
63
+
64
+ return refs
65
+ }
66
+
67
+ /**
68
+ * Builds all TypeScript files from a JSON Schema by traversing
69
+ * all $ref references recursively.
70
+ *
71
+ * Starting from the root schema, this function:
72
+ * 1. Generates a TypeScript file for the root type
73
+ * 2. Extracts all $ref references from the schema
74
+ * 3. Extracts all definitions with $dynamicAnchor (JSON Schema 2020-12 feature)
75
+ * 4. Resolves each ref and generates a TypeScript file for it
76
+ * 5. Recursively processes nested refs until all are handled
77
+ *
78
+ * @param rootSchema - The root JSON Schema to build from
79
+ * @param rootTypeName - The name for the root type (e.g., "Document")
80
+ * @param extensions - Optional map of custom extension properties to add to specific definitions.
81
+ * Keys are definition names (matching $defs keys), values are records of extension property
82
+ * names to their JSON Schema definitions. Extensions are merged as optional properties before
83
+ * type and parser generation.
84
+ * @param typesOnly - When true, only generate TypeScript type definitions without parser functions.
85
+ * @param logWarnings - When true, the generated parsers emit a console.warn for every input key
86
+ * that is not declared in the schema's properties.
87
+ * @param strict - When true, the generated parsers throw on type/shape mismatches
88
+ * (wrong type, missing required property, enum/pattern/min/max violations) instead
89
+ * of coercing invalid input to default values.
90
+ * @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
91
+ * `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
92
+ * as additional `GeneratedFile` entries so the output directory is self-contained.
93
+ * @returns An array of generated TypeScript files
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * const schema = {
98
+ * type: "object",
99
+ * properties: {
100
+ * info: { $ref: "#/$defs/info" }
101
+ * },
102
+ * $defs: {
103
+ * info: {
104
+ * type: "object",
105
+ * properties: {
106
+ * title: { type: "string" }
107
+ * }
108
+ * }
109
+ * }
110
+ * };
111
+ *
112
+ * const files = buildSchema(schema, "Document");
113
+ *
114
+ * // Types-only mode — no parser functions or runtime helpers included:
115
+ * const typesFiles = buildSchema(schema, "Document", undefined, true);
116
+ *
117
+ * // With extensions:
118
+ * const filesWithExtensions = buildSchema(schema, "Document", {
119
+ * info: {
120
+ * 'x-internal': { type: 'boolean' },
121
+ * },
122
+ * });
123
+ * ```
124
+ */
125
+ export const buildSchema = async (
126
+ rootSchema: JSONSchema,
127
+ rootTypeName: string,
128
+ extensions?: SchemaExtensions,
129
+ typesOnly?: boolean,
130
+ logWarnings?: boolean,
131
+ strict?: boolean,
132
+ helpersMode: HelpersMode = 'package',
133
+ ): Promise<GeneratedFile[]> => {
134
+ // Upgrade draft-07 schemas to 2020-12 conventions before processing.
135
+ // This renames `definitions` → `$defs` recursively so the rest of the
136
+ // pipeline can resolve both short-name and URI-keyed refs uniformly.
137
+ rootSchema = upgradeDraft07Schema(rootSchema as Record<string, unknown>) as JSONSchema
138
+
139
+ const files: GeneratedFile[] = []
140
+ const processedRefs = new Set<string>()
141
+ const processedFilenames = new Set<string>()
142
+ const refsToProcess: string[] = []
143
+ const usedHelpers = new Set<RuntimeHelperName>()
144
+
145
+ // Build a map of $dynamicRef anchors to their $ref paths so we can
146
+ // convert $dynamicRef: "#meta" to $ref: "#/$defs/schema" before generating
147
+ const dynamicRefMap = buildDynamicRefMap(rootSchema)
148
+
149
+ // Generate file for the root schema, applying any matching extensions
150
+ const processedRootSchema = resolveDynamicRefs(rootSchema, dynamicRefMap)
151
+ const extendedRootSchema = extensions
152
+ ? applySchemaExtensions(processedRootSchema, rootTypeName.toLowerCase(), extensions)
153
+ : processedRootSchema
154
+ const rootResult = generateFile(extendedRootSchema, rootTypeName, {
155
+ typesOnly: typesOnly ?? false,
156
+ rootSchema: rootSchema as Record<string, unknown>,
157
+ helpersMode,
158
+ ...(logWarnings !== undefined ? { logWarnings } : {}),
159
+ ...(strict !== undefined ? { strict } : {}),
160
+ })
161
+ const rootFilename = rootTypeName.toLowerCase()
162
+
163
+ processedFilenames.add(rootFilename)
164
+ files.push({
165
+ filename: `${rootFilename}.ts`,
166
+ content: rootResult.content,
167
+ })
168
+ for (const helper of rootResult.usedHelpers) usedHelpers.add(helper)
169
+
170
+ // Extract all refs from the root schema
171
+ const rootRefs = extractRefs(rootSchema)
172
+ refsToProcess.push(...rootRefs)
173
+
174
+ // Extract all definitions with $dynamicAnchor
175
+ const dynamicAnchorRefs = extractDynamicAnchorDefs(rootSchema)
176
+ refsToProcess.push(...dynamicAnchorRefs)
177
+
178
+ // Process refs until none remain
179
+ while (refsToProcess.length > 0) {
180
+ const ref = refsToProcess.shift()
181
+
182
+ // Skip if already processed
183
+ if (!ref || processedRefs.has(ref)) {
184
+ continue
185
+ }
186
+
187
+ processedRefs.add(ref)
188
+
189
+ // Resolve the ref to get the actual schema
190
+ const resolvedSchema = resolveRef(ref, rootSchema as Record<string, unknown>)
191
+
192
+ if (!resolvedSchema) {
193
+ console.warn(`Warning: Could not resolve ref: ${ref}`)
194
+ continue
195
+ }
196
+
197
+ // Generate file for this ref, resolving any $dynamicRef to $ref first
198
+ // and applying any matching extensions for this definition
199
+ const typeName = refToName(ref)
200
+ const filename = refToFilename(ref)
201
+ const processedSchema = resolveDynamicRefs(resolvedSchema as JSONSchema, dynamicRefMap)
202
+ const extendedSchema = extensions ? applySchemaExtensions(processedSchema, filename, extensions) : processedSchema
203
+ const fileResult = generateFile(extendedSchema, typeName, {
204
+ typesOnly: typesOnly ?? false,
205
+ selfRef: ref,
206
+ rootSchema: rootSchema as Record<string, unknown>,
207
+ helpersMode,
208
+ ...(logWarnings !== undefined ? { logWarnings } : {}),
209
+ ...(strict !== undefined ? { strict } : {}),
210
+ })
211
+
212
+ if (!processedFilenames.has(filename)) {
213
+ processedFilenames.add(filename)
214
+ files.push({
215
+ filename: `${filename}.ts`,
216
+ content: fileResult.content,
217
+ })
218
+ }
219
+ for (const helper of fileResult.usedHelpers) usedHelpers.add(helper)
220
+
221
+ // Extract refs from this schema and add to queue
222
+ const nestedRefs = extractRefs(resolvedSchema as JSONSchema)
223
+ for (const nestedRef of nestedRefs) {
224
+ if (!processedRefs.has(nestedRef)) {
225
+ refsToProcess.push(nestedRef)
226
+ }
227
+ }
228
+ }
229
+
230
+ // In embedded mode, ship the runtime helper source files alongside the parsers so
231
+ // the output directory is self-contained (no `@amritk/helpers` install required).
232
+ // typesOnly skips parser generation entirely, so no runtime helpers are needed.
233
+ if (helpersMode === 'embedded' && !typesOnly) {
234
+ for (const helper of usedHelpers) {
235
+ const source = await readHelperSource(helper)
236
+ files.push({ filename: `_helpers/${helper}.ts`, content: source })
237
+ }
238
+ }
239
+
240
+ // Generate index.ts with named re-exports extracted from each generated file's content.
241
+ // Regex matches `export type Foo` and `export const foo` declarations.
242
+ const TYPE_EXPORT_RE = /^export type (\w+)/gm
243
+ const CONST_EXPORT_RE = /^export const (\w+)/gm
244
+
245
+ // _helpers/ is an internal directory; never re-export from it.
246
+ const sortedFiles = [...files]
247
+ .filter((f) => !f.filename.startsWith('_helpers/'))
248
+ .sort((a, b) => a.filename.localeCompare(b.filename))
249
+
250
+ let indexContent = ''
251
+ for (const file of sortedFiles) {
252
+ const moduleName = file.filename.replace(/\.ts$/, '')
253
+ const typeNames: string[] = []
254
+ const constNames: string[] = []
255
+
256
+ for (const match of file.content.matchAll(TYPE_EXPORT_RE)) {
257
+ typeNames.push(match[1] as string)
258
+ }
259
+ for (const match of file.content.matchAll(CONST_EXPORT_RE)) {
260
+ constNames.push(match[1] as string)
261
+ }
262
+
263
+ if (typeNames.length === 0 && constNames.length === 0) continue
264
+
265
+ if (typesOnly) {
266
+ indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}';\n`
267
+ } else {
268
+ const typeExports = typeNames.map((n) => `type ${n}`)
269
+ const allExports = [...typeExports, ...constNames]
270
+ indexContent += `export { ${allExports.join(', ')} } from './${moduleName}';\n`
271
+ }
272
+ }
273
+
274
+ files.push({
275
+ filename: 'index.ts',
276
+ content: indexContent,
277
+ })
278
+
279
+ return files
280
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { generateDefaultFromPattern } from './generate-default-from-pattern'
4
+
5
+ describe('generate-default-from-pattern', () => {
6
+ it('returns email default for pattern containing @', () => {
7
+ const result = generateDefaultFromPattern('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')
8
+ expect(result).toBe('"user@example.com"')
9
+ })
10
+
11
+ it('returns email default for pattern with character class and dot', () => {
12
+ const result = generateDefaultFromPattern('[a-zA-Z0-9].*\\.')
13
+ expect(result).toBe('"user@example.com"')
14
+ })
15
+
16
+ it('returns UUID default for hex pattern with {8}', () => {
17
+ const result = generateDefaultFromPattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
18
+ expect(result).toBe('"00000000-0000-0000-0000-000000000000"')
19
+ })
20
+
21
+ it('returns UUID default for pattern with dash separator', () => {
22
+ const result = generateDefaultFromPattern('[0-9a-f]{8}\\-[0-9a-f]{4}')
23
+ expect(result).toBe('"00000000-0000-0000-0000-000000000000"')
24
+ })
25
+
26
+ it('returns null for hex pattern without dash or matching structure', () => {
27
+ const result = generateDefaultFromPattern('^[0-9a-fA-F]{8}')
28
+ expect(result).toBeNull()
29
+ })
30
+
31
+ it('returns URL default for https? pattern', () => {
32
+ const result = generateDefaultFromPattern('^https?://')
33
+ expect(result).toBe('"https://example.com"')
34
+ })
35
+
36
+ it('returns URL default for http pattern', () => {
37
+ const result = generateDefaultFromPattern('^http://')
38
+ expect(result).toBe('"https://example.com"')
39
+ })
40
+
41
+ it('returns semver default for version-like pattern with literal digits', () => {
42
+ // The regex looks for actual digit chars and escaped dots in the pattern string
43
+ const result = generateDefaultFromPattern('3\\.1\\.\\d+')
44
+ expect(result).toBe('"1.0.0"')
45
+ })
46
+
47
+ it('returns null for version pattern without literal digits', () => {
48
+ const result = generateDefaultFromPattern('^\\d+\\.\\d+\\.\\d+$')
49
+ expect(result).toBeNull()
50
+ })
51
+
52
+ it('returns ISO date default for date-like pattern', () => {
53
+ const result = generateDefaultFromPattern('^\\d{4}-\\d{2}-\\d{2}$')
54
+ expect(result).toBe('"2000-01-01"')
55
+ })
56
+
57
+ it('returns phone number default for phone-like pattern with {3} and {4}', () => {
58
+ const result = generateDefaultFromPattern('^\\+?\\d{3}\\d{4}$')
59
+ expect(result).toBe('"+1234567890"')
60
+ })
61
+
62
+ it('returns phone number default for phone-like pattern with {3} and {7}', () => {
63
+ const result = generateDefaultFromPattern('^\\d{3}\\d{7}$')
64
+ expect(result).toBe('"+1234567890"')
65
+ })
66
+
67
+ it('returns null for unrecognized pattern', () => {
68
+ const result = generateDefaultFromPattern('^[a-z]+$')
69
+ expect(result).toBeNull()
70
+ })
71
+
72
+ it('returns null for empty pattern', () => {
73
+ const result = generateDefaultFromPattern('')
74
+ expect(result).toBeNull()
75
+ })
76
+
77
+ it('returns null for simple wildcard pattern', () => {
78
+ const result = generateDefaultFromPattern('.*')
79
+ expect(result).toBeNull()
80
+ })
81
+ })
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Generates a best-guess default value from a regex pattern.
3
+ * Recognizes common patterns and returns appropriate defaults.
4
+ */
5
+ export const generateDefaultFromPattern = (pattern: string): string | null => {
6
+ // Email pattern
7
+ if (pattern.includes('@') || (pattern.includes('[a-zA-Z0-9]') && pattern.includes('\\.'))) {
8
+ return '"user@example.com"'
9
+ }
10
+
11
+ // UUID pattern
12
+ if ((pattern.includes('[0-9a-f]') && pattern.includes('{8}')) || pattern.includes('\\-')) {
13
+ if (pattern.match(/[0-9a-f].*\{8\}/i) || pattern.match(/[0-9a-fA-F]{8}/)) {
14
+ return '"00000000-0000-0000-0000-000000000000"'
15
+ }
16
+ }
17
+
18
+ // URL pattern
19
+ if (pattern.includes('https?') || pattern.includes('http')) {
20
+ return '"https://example.com"'
21
+ }
22
+
23
+ // Semver pattern (3.1.x, x.y.z, etc)
24
+ if (pattern.match(/\d.*\\\.\.*\d/)) {
25
+ return '"1.0.0"'
26
+ }
27
+
28
+ // ISO date pattern
29
+ if (pattern.includes('\\d{4}') && pattern.includes('\\d{2}')) {
30
+ return '"2000-01-01"'
31
+ }
32
+
33
+ // Phone number patterns
34
+ if (pattern.includes('\\d{3}') && (pattern.includes('\\d{4}') || pattern.includes('\\d{7}'))) {
35
+ return '"+1234567890"'
36
+ }
37
+
38
+ return null
39
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { generateDiscriminatedUnionValidation } from './generate-discriminated-union-validation'
4
+
5
+ describe('generate-discriminated-union-validation', () => {
6
+ it('generates ternary chain for schemas with discriminator values', () => {
7
+ const schemas = [
8
+ {
9
+ type: 'object' as const,
10
+ properties: { kind: { const: 'a' }, name: { type: 'string' as const } },
11
+ },
12
+ {
13
+ type: 'object' as const,
14
+ properties: { kind: { const: 'b' }, count: { type: 'number' as const } },
15
+ },
16
+ ]
17
+ const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', true)
18
+ expect(result).toContain('value?.kind === "a"')
19
+ expect(result).toContain('value?.kind === "b"')
20
+ })
21
+
22
+ it('returns fallback expression when no schemas have discriminator values', () => {
23
+ const schemas = [{ type: 'object' as const, properties: { name: { type: 'string' as const } } }]
24
+ const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', true)
25
+ expect(result).toBe('value ?? {}')
26
+ })
27
+
28
+ it('returns accessor when not required and no discriminator values', () => {
29
+ const schemas = [{ type: 'object' as const, properties: { name: { type: 'string' as const } } }]
30
+ const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', false)
31
+ expect(result).toBe('value')
32
+ })
33
+
34
+ it('includes schema checks in ternary when type checks exist', () => {
35
+ const schemas = [
36
+ {
37
+ type: 'object' as const,
38
+ properties: { kind: { const: 'user' } },
39
+ },
40
+ ]
41
+ const result = generateDiscriminatedUnionValidation('input', schemas, 'kind', '{}', true)
42
+ expect(result).toContain('input?.kind === "user"')
43
+ // Should include object type checks
44
+ expect(result).toContain('typeof input === "object"')
45
+ })
46
+
47
+ it('handles schemas with single enum values as discriminators', () => {
48
+ const schemas = [
49
+ {
50
+ type: 'object' as const,
51
+ properties: { type: { enum: ['cat'] } },
52
+ },
53
+ {
54
+ type: 'object' as const,
55
+ properties: { type: { enum: ['dog'] } },
56
+ },
57
+ ]
58
+ const result = generateDiscriminatedUnionValidation('value', schemas, 'type', '{}', true)
59
+ expect(result).toContain('value?.type === "cat"')
60
+ expect(result).toContain('value?.type === "dog"')
61
+ })
62
+
63
+ it('handles empty schemas array', () => {
64
+ const result = generateDiscriminatedUnionValidation('value', [], 'type', '{}', true)
65
+ expect(result).toBe('value ?? {}')
66
+ })
67
+
68
+ it('skips schemas without a discriminator value', () => {
69
+ const schemas = [
70
+ {
71
+ type: 'object' as const,
72
+ properties: { kind: { const: 'a' } },
73
+ },
74
+ {
75
+ type: 'object' as const,
76
+ properties: { name: { type: 'string' as const } },
77
+ },
78
+ ]
79
+ const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', true)
80
+ expect(result).toContain('value?.kind === "a"')
81
+ // The second schema has no discriminator, so it does not appear in the ternary chain
82
+ expect(result).not.toContain('value?.kind === undefined')
83
+ })
84
+ })
@@ -0,0 +1,54 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
2
+ import { getDefaultValue } from '#helpers/get-default-value'
3
+ import { getDiscriminatorValue } from '#helpers/get-discriminator-value'
4
+
5
+ import { generateSchemaChecks } from './generate-schema-checks'
6
+
7
+ /**
8
+ * Generates a discriminated union validation expression.
9
+ * Uses a discriminator property to efficiently choose the correct schema.
10
+ */
11
+ export const generateDiscriminatedUnionValidation = (
12
+ accessor: string,
13
+ schemas: readonly JSONSchema[],
14
+ discriminatorKey: string,
15
+ defaultValue: string,
16
+ isRequired: boolean,
17
+ ): string => {
18
+ const cases: string[] = []
19
+
20
+ for (const schema of schemas) {
21
+ const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey)
22
+ if (discriminatorValue !== null) {
23
+ const schemaDefault = getDefaultValue(schema)
24
+ const checks = generateSchemaChecks(accessor, schema)
25
+
26
+ if (checks.length > 0) {
27
+ // checks[0] is safe: we guard with checks.length > 0 above
28
+ let combinedChecks = checks[0] as string
29
+ for (let i = 1; i < checks.length; i++) {
30
+ combinedChecks += ' && ' + checks[i]
31
+ }
32
+ cases.push(
33
+ `${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} && ${combinedChecks} ? ${accessor} : ${schemaDefault}`,
34
+ )
35
+ } else {
36
+ cases.push(
37
+ `${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} ? ${accessor} : ${schemaDefault}`,
38
+ )
39
+ }
40
+ }
41
+ }
42
+
43
+ if (cases.length === 0) {
44
+ return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
45
+ }
46
+
47
+ // Chain the cases together with ternary operators using a for loop instead of .reduce()
48
+ // cases[0] is safe: we guard with cases.length === 0 above
49
+ let result = cases[0] as string
50
+ for (let i = 1; i < cases.length; i++) {
51
+ result = cases[i] + ' : ' + result
52
+ }
53
+ return result
54
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { generateEnumCheck } from './generate-enum-check'
4
+
5
+ describe('generate-enum-check', () => {
6
+ it('generates an includes check for string enum values', () => {
7
+ const result = generateEnumCheck('value', ['a', 'b', 'c'])
8
+ expect(result).toBe('["a","b","c"].includes(value as never)')
9
+ })
10
+
11
+ it('generates an includes check for numeric enum values', () => {
12
+ const result = generateEnumCheck('value', [1, 2, 3])
13
+ expect(result).toBe('[1,2,3].includes(value as never)')
14
+ })
15
+
16
+ it('generates an includes check for mixed type enum values', () => {
17
+ const result = generateEnumCheck('value', ['a', 1, true, null])
18
+ expect(result).toBe('["a",1,true,null].includes(value as never)')
19
+ })
20
+
21
+ it('handles a single enum value', () => {
22
+ const result = generateEnumCheck('value', ['only'])
23
+ expect(result).toBe('["only"].includes(value as never)')
24
+ })
25
+
26
+ it('handles an empty enum array', () => {
27
+ const result = generateEnumCheck('value', [])
28
+ expect(result).toBe('[].includes(value as never)')
29
+ })
30
+
31
+ it('uses the provided accessor in the expression', () => {
32
+ const result = generateEnumCheck('input?.type', ['a', 'b'])
33
+ expect(result).toBe('["a","b"].includes(input?.type as never)')
34
+ })
35
+
36
+ it('handles enum values with special characters', () => {
37
+ const result = generateEnumCheck('value', ['hello world', 'foo"bar'])
38
+ expect(result).toBe('["hello world","foo\\"bar"].includes(value as never)')
39
+ })
40
+
41
+ it('handles boolean enum values', () => {
42
+ const result = generateEnumCheck('value', [true, false])
43
+ expect(result).toBe('[true,false].includes(value as never)')
44
+ })
45
+ })
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Generates an enum validation check expression.
3
+ * Returns a string that checks if the value is in the allowed enum values.
4
+ */
5
+ export const generateEnumCheck = (accessor: string, enumValues: readonly unknown[]): string => {
6
+ const serializedEnum = JSON.stringify(enumValues)
7
+ return `${serializedEnum}.includes(${accessor} as never)`
8
+ }