@kubb/plugin-ts 5.0.0-beta.4 → 5.0.0-beta.56

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,3 +1,4 @@
1
+ import { resolveContentTypeVariants } from '@internals/shared'
1
2
  import { ast, defineGenerator } from '@kubb/core'
2
3
  import { File, jsxRenderer } from '@kubb/renderer-jsx'
3
4
  import { Type } from '../components/Type.tsx'
@@ -6,45 +7,52 @@ import { printerTs } from '../printers/printerTs.ts'
6
7
  import type { PluginTs } from '../types'
7
8
  import { buildData, buildResponses, buildResponseUnion } from '../utils.ts'
8
9
 
10
+ /**
11
+ * Built-in generator for `@kubb/plugin-ts`. Emits one TypeScript file per
12
+ * schema in the spec plus per-operation request, response, and parameter
13
+ * types. Drop-replace with a custom `Generator<PluginTs>` to change how
14
+ * TypeScript output is produced.
15
+ */
9
16
  export const typeGenerator = defineGenerator<PluginTs>({
10
17
  name: 'typescript',
11
18
  renderer: jsxRenderer,
12
19
  schema(node, ctx) {
13
- const { enumType, enumTypeSuffix, enumKeyCasing, syntaxType, optionalType, arrayType, output, group, printer } = ctx.options
20
+ const { enum: enumOptions, syntaxType, optionalType, arrayType, output, group, printer } = ctx.options
14
21
  const { adapter, config, resolver, root } = ctx
15
22
 
16
23
  if (!node.name) {
17
24
  return
18
25
  }
19
- const mode = ctx.getMode(output)
20
26
  // Build a set of schema names that are enums so the ref handler and getImports
21
27
  // callback can use the suffixed type name (e.g. `StatusKey`) for those refs.
22
- const enumSchemaNames = new Set((adapter.inputNode?.schemas ?? []).filter((s) => ast.narrowSchema(s, ast.schemaTypes.enum) && s.name).map((s) => s.name!))
28
+ const enumSchemaNames = new Set<string>(ctx.meta.enumNames)
23
29
 
24
30
  function resolveImportName(schemaName: string): string {
25
- if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && enumTypeSuffix && enumSchemaNames.has(schemaName)) {
26
- return resolver.resolveEnumKeyName({ name: schemaName }, enumTypeSuffix)
31
+ if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumOptions.type) && enumOptions.typeSuffix && enumSchemaNames.has(schemaName)) {
32
+ return resolver.resolveEnumKeyName({ name: schemaName }, enumOptions.typeSuffix)
27
33
  }
28
34
  return resolver.resolveTypeName(schemaName)
29
35
  }
30
36
 
31
37
  const imports = adapter.getImports(node, (schemaName) => ({
32
38
  name: resolveImportName(schemaName),
33
- path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
39
+ path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
34
40
  }))
35
41
 
36
42
  const isEnumSchema = !!ast.narrowSchema(node, ast.schemaTypes.enum)
37
43
 
38
44
  const meta = {
39
- name: ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && isEnumSchema ? resolver.resolveEnumKeyName(node, enumTypeSuffix) : resolver.resolveTypeName(node.name),
40
- file: resolver.resolveFile({ name: node.name, extname: '.ts' }, { root, output, group }),
45
+ name:
46
+ ENUM_TYPES_WITH_KEY_SUFFIX.has(enumOptions.type) && isEnumSchema
47
+ ? resolver.resolveEnumKeyName(node, enumOptions.typeSuffix)
48
+ : resolver.resolveTypeName(node.name),
49
+ file: resolver.resolveFile({ name: node.name, extname: '.ts' }, { root, output, group: group ?? undefined }),
41
50
  } as const
42
51
 
43
52
  const schemaPrinter = printerTs({
44
53
  optionalType,
45
54
  arrayType,
46
- enumType,
47
- enumTypeSuffix,
55
+ enum: enumOptions,
48
56
  name: meta.name,
49
57
  syntaxType,
50
58
  description: node.description,
@@ -58,61 +66,52 @@ export const typeGenerator = defineGenerator<PluginTs>({
58
66
  baseName={meta.file.baseName}
59
67
  path={meta.file.path}
60
68
  meta={meta.file.meta}
61
- banner={resolver.resolveBanner(adapter.inputNode, { output, config })}
62
- footer={resolver.resolveFooter(adapter.inputNode, { output, config })}
69
+ banner={resolver.resolveBanner(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
70
+ footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
63
71
  >
64
- {mode === 'split' &&
65
- imports.map((imp) => (
66
- <File.Import key={[node.name, imp.path, imp.isTypeOnly].join('-')} root={meta.file.path} path={imp.path} name={imp.name} isTypeOnly />
67
- ))}
68
- <Type
69
- name={meta.name}
70
- node={node}
71
- enumType={enumType}
72
- enumTypeSuffix={enumTypeSuffix}
73
- enumKeyCasing={enumKeyCasing}
74
- resolver={resolver}
75
- printer={schemaPrinter}
76
- />
72
+ {imports.map((imp) => (
73
+ <File.Import key={[node.name, imp.path, imp.isTypeOnly].join('-')} root={meta.file.path} path={imp.path} name={imp.name} isTypeOnly />
74
+ ))}
75
+ <Type name={meta.name} node={node} enum={enumOptions} resolver={resolver} printer={schemaPrinter} />
77
76
  </File>
78
77
  )
79
78
  },
80
79
  operation(node, ctx) {
81
- const { enumType, enumTypeSuffix, enumKeyCasing, optionalType, arrayType, syntaxType, paramsCasing, group, output, printer } = ctx.options
80
+ const { enum: enumOptions, optionalType, arrayType, syntaxType, paramsCasing, group, output, printer } = ctx.options
82
81
  const { adapter, config, resolver, root } = ctx
83
82
 
84
- const mode = ctx.getMode(output)
85
-
86
83
  const params = ast.caseParams(node.parameters, paramsCasing)
87
84
 
88
85
  const meta = {
89
- file: resolver.resolveFile({ name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path }, { root, output, group }),
86
+ file: resolver.resolveFile(
87
+ { name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path },
88
+ { root, output, group: group ?? undefined },
89
+ ),
90
90
  } as const
91
91
 
92
92
  // Build a set of schema names that are enums so the ref handler and getImports
93
93
  // callback can use the suffixed type name (e.g. `StatusKey`) for those refs.
94
- const enumSchemaNames = new Set((adapter.inputNode?.schemas ?? []).filter((s) => ast.narrowSchema(s, ast.schemaTypes.enum) && s.name).map((s) => s.name!))
94
+ const enumSchemaNames = new Set<string>(ctx.meta.enumNames)
95
95
 
96
96
  function resolveImportName(schemaName: string): string {
97
- if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && enumTypeSuffix && enumSchemaNames.has(schemaName)) {
98
- return resolver.resolveEnumKeyName({ name: schemaName }, enumTypeSuffix)
97
+ if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumOptions.type) && enumOptions.typeSuffix && enumSchemaNames.has(schemaName)) {
98
+ return resolver.resolveEnumKeyName({ name: schemaName }, enumOptions.typeSuffix)
99
99
  }
100
100
  return resolver.resolveTypeName(schemaName)
101
101
  }
102
102
 
103
- function renderSchemaType({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array<string> }) {
103
+ function renderSchemaType({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array<string> | null }) {
104
104
  if (!schema) return null
105
105
 
106
106
  const imports = adapter.getImports(schema, (schemaName) => ({
107
107
  name: resolveImportName(schemaName),
108
- path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
108
+ path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
109
109
  }))
110
110
 
111
111
  const schemaPrinter = printerTs({
112
112
  optionalType,
113
113
  arrayType,
114
- enumType,
115
- enumTypeSuffix,
114
+ enum: enumOptions,
116
115
  name,
117
116
  syntaxType,
118
117
  description: schema.description,
@@ -124,19 +123,38 @@ export const typeGenerator = defineGenerator<PluginTs>({
124
123
 
125
124
  return (
126
125
  <>
127
- {mode === 'split' &&
128
- imports.map((imp) => (
129
- <File.Import key={[name, imp.path, imp.isTypeOnly].join('-')} root={meta.file.path} path={imp.path} name={imp.name} isTypeOnly />
130
- ))}
131
- <Type
132
- name={name}
133
- node={schema}
134
- enumType={enumType}
135
- enumTypeSuffix={enumTypeSuffix}
136
- enumKeyCasing={enumKeyCasing}
137
- resolver={resolver}
138
- printer={schemaPrinter}
139
- />
126
+ {imports.map((imp) => (
127
+ <File.Import key={[name, imp.path, imp.isTypeOnly].join('-')} root={meta.file.path} path={imp.path} name={imp.name} isTypeOnly />
128
+ ))}
129
+ <Type name={name} node={schema} enum={enumOptions} resolver={resolver} printer={schemaPrinter} />
130
+ </>
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Emits an individual type per content type plus a union alias under `baseName`.
136
+ * Shared by the request body and multi-content-type responses.
137
+ */
138
+ function buildContentTypeVariants(
139
+ entries: Array<{ contentType: string; schema?: ast.SchemaNode | null; keysToOmit?: Array<string> | null }>,
140
+ baseName: string,
141
+ decorate?: (schema: ast.SchemaNode) => ast.SchemaNode,
142
+ ) {
143
+ const variants = resolveContentTypeVariants(entries, baseName)
144
+ const unionSchema = ast.createSchema({
145
+ type: 'union',
146
+ members: variants.map((variant) => ast.createSchema({ type: 'ref', name: variant.name })),
147
+ })
148
+ return (
149
+ <>
150
+ {variants.map((variant) =>
151
+ renderSchemaType({
152
+ schema: decorate ? decorate(variant.schema) : variant.schema,
153
+ name: variant.name,
154
+ keysToOmit: variant.keysToOmit,
155
+ }),
156
+ )}
157
+ {renderSchemaType({ schema: unionSchema, name: baseName })}
140
158
  </>
141
159
  )
142
160
  }
@@ -148,24 +166,44 @@ export const typeGenerator = defineGenerator<PluginTs>({
148
166
  }),
149
167
  )
150
168
 
151
- const requestType = node.requestBody?.content?.[0]?.schema
152
- ? renderSchemaType({
169
+ const requestBodyContent = node.requestBody?.content ?? []
170
+
171
+ function buildRequestType() {
172
+ if (requestBodyContent.length === 0) return null
173
+ if (requestBodyContent.length === 1) {
174
+ const entry = requestBodyContent[0]!
175
+ if (!entry.schema) return null
176
+ return renderSchemaType({
153
177
  schema: {
154
- ...node.requestBody.content![0]!.schema!,
155
- description: node.requestBody.description ?? node.requestBody.content![0]!.schema!.description,
178
+ ...entry.schema,
179
+ description: node.requestBody!.description ?? entry.schema.description,
156
180
  },
157
181
  name: resolver.resolveDataName(node),
158
- keysToOmit: node.requestBody.content![0]!.keysToOmit,
182
+ keysToOmit: entry.keysToOmit,
159
183
  })
160
- : null
184
+ }
185
+ // Multiple content types — generate individual types + union alias
186
+ return buildContentTypeVariants(requestBodyContent, resolver.resolveDataName(node), (schema) => ({
187
+ ...schema,
188
+ description: node.requestBody!.description ?? schema.description,
189
+ }))
190
+ }
161
191
 
162
- const responseTypes = node.responses.map((res) =>
163
- renderSchemaType({
164
- schema: res.schema,
192
+ const requestType = buildRequestType()
193
+
194
+ const responseTypes = node.responses.map((res) => {
195
+ const variants = (res.content ?? []).filter((entry) => entry.schema)
196
+ // Multiple content types for a single status code — generate a union of the variants.
197
+ if (variants.length > 1) {
198
+ return buildContentTypeVariants(variants, resolver.resolveResponseStatusName(node, res.statusCode))
199
+ }
200
+ const primary = variants[0] ?? res.content?.[0]
201
+ return renderSchemaType({
202
+ schema: primary?.schema ?? null,
165
203
  name: resolver.resolveResponseStatusName(node, res.statusCode),
166
- keysToOmit: res.keysToOmit,
167
- }),
168
- )
204
+ keysToOmit: primary?.keysToOmit,
205
+ })
206
+ })
169
207
 
170
208
  const dataType = renderSchemaType({
171
209
  schema: buildData({ ...node, parameters: params }, { resolver }),
@@ -177,26 +215,27 @@ export const typeGenerator = defineGenerator<PluginTs>({
177
215
  name: resolver.resolveResponsesName(node),
178
216
  })
179
217
 
180
- const responseType = (() => {
181
- if (!node.responses.some((res) => res.schema)) {
218
+ function buildResponseType() {
219
+ const hasSchema = (res: ast.ResponseNode) => (res.content ?? []).some((entry) => entry.schema)
220
+ if (!node.responses.some(hasSchema)) {
182
221
  return null
183
222
  }
184
223
 
185
224
  const responseName = resolver.resolveResponseName(node)
186
225
 
187
- // Skip generating the response union type when an imported component schema
188
- // has the same resolved name to avoid redeclaration errors.
189
- const responsesWithSchema = node.responses.filter((res) => res.schema)
226
+ const responsesWithSchema = node.responses.filter(hasSchema)
190
227
  const importedNames = new Set(
191
228
  responsesWithSchema.flatMap((res) =>
192
- res.schema
193
- ? adapter
194
- .getImports(res.schema, (schemaName) => ({
195
- name: resolveImportName(schemaName),
196
- path: '',
197
- }))
198
- .flatMap((imp) => (Array.isArray(imp.name) ? imp.name : [imp.name]))
199
- : [],
229
+ (res.content ?? []).flatMap((entry) =>
230
+ entry.schema
231
+ ? adapter
232
+ .getImports(entry.schema, (schemaName) => ({
233
+ name: resolveImportName(schemaName),
234
+ path: '',
235
+ }))
236
+ .flatMap((imp) => (Array.isArray(imp.name) ? imp.name : [imp.name]))
237
+ : [],
238
+ ),
200
239
  ),
201
240
  )
202
241
 
@@ -211,15 +250,17 @@ export const typeGenerator = defineGenerator<PluginTs>({
211
250
  },
212
251
  name: responseName,
213
252
  })
214
- })()
253
+ }
254
+
255
+ const responseType = buildResponseType()
215
256
 
216
257
  return (
217
258
  <File
218
259
  baseName={meta.file.baseName}
219
260
  path={meta.file.path}
220
261
  meta={meta.file.meta}
221
- banner={resolver.resolveBanner(adapter.inputNode, { output, config })}
222
- footer={resolver.resolveFooter(adapter.inputNode, { output, config })}
262
+ banner={resolver.resolveBanner(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
263
+ footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
223
264
  >
224
265
  {paramTypes}
225
266
  {responseTypes}
package/src/plugin.ts CHANGED
@@ -1,40 +1,47 @@
1
- import { camelCase } from '@internals/utils'
2
- import { definePlugin, type Group } from '@kubb/core'
1
+ import { createGroupConfig } from '@internals/shared'
2
+ import { definePlugin } from '@kubb/core'
3
3
  import { typeGenerator } from './generators/typeGenerator.tsx'
4
4
  import { resolverTs } from './resolvers/resolverTs.ts'
5
5
  import type { PluginTs } from './types.ts'
6
6
 
7
7
  /**
8
- * Canonical plugin name for `@kubb/plugin-ts`, used to identify the plugin in driver lookups and warnings.
8
+ * Canonical plugin name for `@kubb/plugin-ts`. Used for driver lookups and
9
+ * cross-plugin dependency references.
9
10
  */
10
11
  export const pluginTsName = 'plugin-ts' satisfies PluginTs['name']
11
12
 
12
13
  /**
13
- * The `@kubb/plugin-ts` plugin factory.
14
- *
15
- * Generates TypeScript type declarations from an OpenAPI/AST `RootNode`.
16
- * Walks schemas and operations, delegates rendering to the active generators,
17
- * and writes barrel files based on `output.barrelType`.
14
+ * Generates TypeScript `type` aliases and `interface` declarations from an
15
+ * OpenAPI spec. The foundation that every other Kubb plugin builds on:
16
+ * clients, query hooks, mocks, and validators all reference the names this
17
+ * plugin produces.
18
18
  *
19
19
  * @example
20
20
  * ```ts
21
- * import pluginTs from '@kubb/plugin-ts'
21
+ * import { defineConfig } from 'kubb'
22
+ * import { pluginTs } from '@kubb/plugin-ts'
22
23
  *
23
24
  * export default defineConfig({
24
- * plugins: [pluginTs({ output: { path: 'types' }, enumType: 'asConst' })],
25
+ * input: { path: './petStore.yaml' },
26
+ * output: { path: './src/gen' },
27
+ * plugins: [
28
+ * pluginTs({
29
+ * output: { path: './types' },
30
+ * enum: { type: 'asConst' },
31
+ * optionalType: 'questionTokenAndUndefined',
32
+ * }),
33
+ * ],
25
34
  * })
26
35
  * ```
27
36
  */
28
37
  export const pluginTs = definePlugin<PluginTs>((options) => {
29
38
  const {
30
- output = { path: 'types', barrelType: 'named' },
39
+ output = { path: 'types', barrel: { type: 'named' } },
31
40
  group,
32
41
  exclude = [],
33
42
  include,
34
43
  override = [],
35
- enumType = 'asConst',
36
- enumTypeSuffix = 'Key',
37
- enumKeyCasing = 'none',
44
+ enum: enumOptions = {},
38
45
  optionalType = 'questionToken',
39
46
  arrayType = 'array',
40
47
  syntaxType = 'type',
@@ -45,17 +52,14 @@ export const pluginTs = definePlugin<PluginTs>((options) => {
45
52
  generators: userGenerators = [],
46
53
  } = options
47
54
 
48
- const groupConfig = group
49
- ? ({
50
- ...group,
51
- name: (ctx) => {
52
- if (group.type === 'path') {
53
- return `${ctx.group.split('/')[1]}`
54
- }
55
- return `${camelCase(ctx.group)}Controller`
56
- },
57
- } satisfies Group)
58
- : undefined
55
+ const groupConfig = createGroupConfig(group)
56
+
57
+ const resolvedEnum = {
58
+ type: enumOptions.type ?? 'asConst',
59
+ constCasing: enumOptions.constCasing ?? 'camelCase',
60
+ typeSuffix: enumOptions.typeSuffix ?? 'Key',
61
+ keyCasing: enumOptions.keyCasing ?? 'none',
62
+ }
59
63
 
60
64
  return {
61
65
  name: pluginTsName,
@@ -70,9 +74,7 @@ export const pluginTs = definePlugin<PluginTs>((options) => {
70
74
  optionalType,
71
75
  group: groupConfig,
72
76
  arrayType,
73
- enumType,
74
- enumTypeSuffix,
75
- enumKeyCasing,
77
+ enum: resolvedEnum,
76
78
  syntaxType,
77
79
  paramsCasing,
78
80
  printer,
@@ -64,11 +64,11 @@ function rank(param: ast.FunctionParameterNode | ast.ParameterGroupNode): number
64
64
  }
65
65
 
66
66
  function sortParams(params: ReadonlyArray<ast.FunctionParameterNode | ast.ParameterGroupNode>): Array<ast.FunctionParameterNode | ast.ParameterGroupNode> {
67
- return [...params].sort((a, b) => rank(a) - rank(b))
67
+ return params.toSorted((a, b) => rank(a) - rank(b))
68
68
  }
69
69
 
70
70
  function sortChildParams(params: Array<ast.FunctionParameterNode>): Array<ast.FunctionParameterNode> {
71
- return [...params].sort((a, b) => rank(a) - rank(b))
71
+ return params.toSorted((a, b) => rank(a) - rank(b))
72
72
  }
73
73
 
74
74
  /**
@@ -1,11 +1,16 @@
1
+ import { extractRefName } from '@kubb/ast/utils'
1
2
  import { ast } from '@kubb/core'
2
- import { safePrint } from '@kubb/parser-ts'
3
+ import { parserTs } from '@kubb/parser-ts'
3
4
  import type ts from 'typescript'
4
5
  import { ENUM_TYPES_WITH_KEY_SUFFIX, OPTIONAL_ADDS_QUESTION_TOKEN, OPTIONAL_ADDS_UNDEFINED } from '../constants.ts'
5
6
  import * as factory from '../factory.ts'
6
7
  import type { PluginTs, ResolverTs } from '../types.ts'
7
8
  import { buildPropertyJSDocComments } from '../utils.ts'
8
9
 
10
+ function isNonNullable<T>(value: T | null | undefined): value is T {
11
+ return value !== null && value !== undefined
12
+ }
13
+
9
14
  /**
10
15
  * Partial map of node-type overrides for the TypeScript printer.
11
16
  *
@@ -46,23 +51,11 @@ export type PrinterTsOptions = {
46
51
  */
47
52
  arrayType: PluginTs['resolvedOptions']['arrayType']
48
53
  /**
49
- * Enum output format.
50
- * - `'inlineLiteral'` embeds literal unions inline
51
- * - `'asPascalConst'` generates named const unions
52
- * - `'asConst'` generates as const declarations
53
- *
54
- * @default `'inlineLiteral'`
55
- */
56
- enumType: PluginTs['resolvedOptions']['enumType']
57
- /**
58
- * Suffix appended to enum key reference names.
59
- *
60
- * @example Enum key naming
61
- * `StatusKey` when `enumType` is `'asConst'`
62
- *
63
- * @default `'Key'`
54
+ * Grouped enum settings. The printer emits references to enums, not the enum declarations, so only
55
+ * `type` (the output format) and `typeSuffix` (the enum key reference suffix) matter here.
56
+ * `constCasing` and `keyCasing` are ignored.
64
57
  */
65
- enumTypeSuffix?: PluginTs['resolvedOptions']['enumTypeSuffix']
58
+ enum: PluginTs['resolvedOptions']['enum']
66
59
  /**
67
60
  * Syntax for generated declarations.
68
61
  * - `'type'` generates type aliases
@@ -85,7 +78,7 @@ export type PrinterTsOptions = {
85
78
  * Properties to exclude using `Omit<Type, Keys>`.
86
79
  * Forces type alias syntax regardless of `syntaxType` setting.
87
80
  */
88
- keysToOmit?: Array<string>
81
+ keysToOmit?: Array<string> | null
89
82
  /**
90
83
  * Transforms raw schema names into valid TypeScript identifiers.
91
84
  */
@@ -120,13 +113,13 @@ type PrinterTs = PrinterTsFactory
120
113
  *
121
114
  * @example Raw type node (no `typeName`)
122
115
  * ```ts
123
- * const printer = printerTs({ optionalType: 'questionToken', arrayType: 'array', enumType: 'inlineLiteral' })
116
+ * const printer = printerTs({ optionalType: 'questionToken', arrayType: 'array', enum: { type: 'inlineLiteral' } })
124
117
  * const typeNode = printer.print(schemaNode) // ts.TypeNode
125
118
  * ```
126
119
  *
127
120
  * @example Full declaration (with `typeName`)
128
121
  * ```ts
129
- * const printer = printerTs({ optionalType: 'questionToken', arrayType: 'array', enumType: 'inlineLiteral', typeName: 'MyType' })
122
+ * const printer = printerTs({ optionalType: 'questionToken', arrayType: 'array', enum: { type: 'inlineLiteral' }, typeName: 'MyType' })
130
123
  * const declaration = printer.print(schemaNode) // ts.TypeAliasDeclaration | ts.InterfaceDeclaration
131
124
  * ```
132
125
  */
@@ -163,21 +156,21 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
163
156
  time: factory.dateOrStringNode,
164
157
  ref(node) {
165
158
  if (!node.name) {
166
- return undefined
159
+ return null
167
160
  }
168
161
  // Parser-generated refs (with $ref) carry raw schema names that need resolving.
169
162
  // Use the canonical name from the $ref path — node.name may have been overridden
170
163
  // (e.g. by single-member allOf flatten using the property-derived child name).
171
164
  // Inline refs (without $ref) from utils already carry resolved type names.
172
- const refName = node.ref ? (ast.extractRefName(node.ref) ?? node.name) : node.name
165
+ const refName = node.ref ? (extractRefName(node.ref) ?? node.name) : node.name
173
166
 
174
167
  // When a Key suffix is configured, enum refs must use the suffixed name (e.g. `StatusKey`)
175
168
  // so the reference matches what the enum file actually exports.
176
169
  const isEnumRef =
177
- node.ref && ENUM_TYPES_WITH_KEY_SUFFIX.has(this.options.enumType) && this.options.enumTypeSuffix && this.options.enumSchemaNames?.has(refName)
170
+ node.ref && ENUM_TYPES_WITH_KEY_SUFFIX.has(this.options.enum.type) && this.options.enum.typeSuffix && this.options.enumSchemaNames?.has(refName)
178
171
 
179
172
  const name = isEnumRef
180
- ? this.options.resolver.resolveEnumKeyName({ name: refName }, this.options.enumTypeSuffix!)
173
+ ? this.options.resolver.resolveEnumKeyName({ name: refName }, this.options.enum.typeSuffix)
181
174
  : node.ref
182
175
  ? this.options.resolver.default(refName, 'type')
183
176
  : refName
@@ -187,18 +180,18 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
187
180
  enum(node) {
188
181
  const values = node.namedEnumValues?.map((v) => v.value) ?? node.enumValues ?? []
189
182
 
190
- if (this.options.enumType === 'inlineLiteral' || !node.name) {
183
+ if (this.options.enum.type === 'inlineLiteral' || !node.name) {
191
184
  const literalNodes = values
192
185
  .filter((v): v is string | number | boolean => v !== null && v !== undefined)
193
186
  .map((value) => factory.constToTypeNode(value, typeof value as 'string' | 'number' | 'boolean'))
194
- .filter(Boolean)
187
+ .filter(isNonNullable)
195
188
 
196
189
  return factory.createUnionDeclaration({ withParentheses: true, nodes: literalNodes }) ?? undefined
197
190
  }
198
191
 
199
192
  const resolvedName =
200
- ENUM_TYPES_WITH_KEY_SUFFIX.has(this.options.enumType) && this.options.enumTypeSuffix
201
- ? this.options.resolver.resolveEnumKeyName(node, this.options.enumTypeSuffix)
193
+ ENUM_TYPES_WITH_KEY_SUFFIX.has(this.options.enum.type) && this.options.enum.typeSuffix
194
+ ? this.options.resolver.resolveEnumKeyName(node, this.options.enum.typeSuffix)
202
195
  : this.options.resolver.default(node.name, 'type')
203
196
 
204
197
  return factory.createTypeReferenceNode(resolvedName, undefined)
@@ -224,7 +217,7 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
224
217
 
225
218
  return this.transform(m)
226
219
  })
227
- .filter(Boolean)
220
+ .filter(isNonNullable)
228
221
 
229
222
  return factory.createUnionDeclaration({ withParentheses: true, nodes: memberNodes }) ?? undefined
230
223
  }
@@ -232,15 +225,15 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
232
225
  return factory.createUnionDeclaration({ withParentheses: true, nodes: factory.buildMemberNodes(members, this.transform) }) ?? undefined
233
226
  },
234
227
  intersection(node) {
235
- return factory.createIntersectionDeclaration({ withParentheses: true, nodes: factory.buildMemberNodes(node.members, this.transform) }) ?? undefined
228
+ return factory.createIntersectionDeclaration({ withParentheses: true, nodes: factory.buildMemberNodes(node.members, this.transform) }) ?? null
236
229
  },
237
230
  array(node) {
238
- const itemNodes = (node.items ?? []).map((item) => this.transform(item)).filter(Boolean)
231
+ const itemNodes = (node.items ?? []).map((item) => this.transform(item)).filter(isNonNullable)
239
232
 
240
- return factory.createArrayDeclaration({ nodes: itemNodes, arrayType: this.options.arrayType }) ?? undefined
233
+ return factory.createArrayDeclaration({ nodes: itemNodes, arrayType: this.options.arrayType }) ?? null
241
234
  },
242
235
  tuple(node) {
243
- return factory.buildTupleNode(node, this.transform)
236
+ return factory.buildTupleNode(node, this.transform) ?? null
244
237
  },
245
238
  object(node) {
246
239
  const { transform, options } = this
@@ -275,36 +268,33 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
275
268
  print(node) {
276
269
  const { name, syntaxType = 'type', description, keysToOmit } = this.options
277
270
 
278
- let base = this.transform(node)
279
- if (!base) return null
271
+ const transformed = this.transform(node)
272
+ if (!transformed) return null
280
273
 
281
274
  // For ref nodes, structural metadata lives on node.schema rather than the ref node itself.
282
275
  const meta = ast.syncSchemaRef(node)
283
276
 
284
277
  // Without name, apply modifiers inline and return.
285
278
  if (!name) {
286
- if (meta.nullable) {
287
- base = factory.createUnionDeclaration({ nodes: [base, factory.keywordTypeNodes.null] })
288
- }
289
- if ((meta.nullish || meta.optional) && addsUndefined) {
290
- base = factory.createUnionDeclaration({ nodes: [base, factory.keywordTypeNodes.undefined] })
291
- }
292
- return safePrint(base)
279
+ const withNullable = meta.nullable ? factory.createUnionDeclaration({ nodes: [transformed, factory.keywordTypeNodes.null] }) : transformed
280
+ const result =
281
+ (meta.nullish || meta.optional) && addsUndefined
282
+ ? factory.createUnionDeclaration({ nodes: [withNullable, factory.keywordTypeNodes.undefined] })
283
+ : withNullable
284
+ return parserTs.print(result)
293
285
  }
294
286
 
295
287
  // When keysToOmit is present, wrap with Omit first, then apply nullable/optional
296
288
  // modifiers so they are not swallowed by NonNullable inside createOmitDeclaration.
297
- let inner: ts.TypeNode = keysToOmit?.length ? factory.createOmitDeclaration({ keys: keysToOmit, type: base, nonNullable: true }) : base
298
-
299
- if (meta.nullable) {
300
- inner = factory.createUnionDeclaration({ nodes: [inner, factory.keywordTypeNodes.null] })
301
- }
302
-
303
- // For named type declarations (type aliases), optional/nullish always produces | undefined
304
- // regardless of optionalType the questionToken ? modifier only applies to object properties.
305
- if (meta.nullish || meta.optional) {
306
- inner = factory.createUnionDeclaration({ nodes: [inner, factory.keywordTypeNodes.undefined] })
307
- }
289
+ const inner = (() => {
290
+ const omitted: ts.TypeNode = keysToOmit?.length
291
+ ? factory.createOmitDeclaration({ keys: keysToOmit, type: transformed, nonNullable: true })
292
+ : transformed
293
+ const withNullable = meta.nullable ? factory.createUnionDeclaration({ nodes: [omitted, factory.keywordTypeNodes.null] }) : omitted
294
+ // For named type declarations (type aliases), optional/nullish always produces | undefined
295
+ // regardless of optionalType the questionToken ? modifier only applies to object properties.
296
+ return meta.nullish || meta.optional ? factory.createUnionDeclaration({ nodes: [withNullable, factory.keywordTypeNodes.undefined] }) : withNullable
297
+ })()
308
298
 
309
299
  const useTypeGeneration = syntaxType === 'type' || inner.kind === factory.syntaxKind.union || !!keysToOmit?.length
310
300
 
@@ -319,7 +309,7 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
319
309
  }),
320
310
  })
321
311
 
322
- return safePrint(typeNode)
312
+ return parserTs.print(typeNode)
323
313
  },
324
314
  }
325
315
  })