@kubb/plugin-ts 5.0.0-beta.3 → 5.0.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,19 +1,16 @@
1
1
  {
2
2
  "name": "@kubb/plugin-ts",
3
- "version": "5.0.0-beta.3",
4
- "description": "TypeScript code generation plugin for Kubb, transforming OpenAPI schemas into TypeScript interfaces, types, and utility functions.",
3
+ "version": "5.0.0-beta.30",
4
+ "description": "Generate TypeScript types, interfaces, and enums from your OpenAPI specification. The foundational plugin that powers type safety across the entire Kubb ecosystem.",
5
5
  "keywords": [
6
- "code-generator",
6
+ "code-generation",
7
7
  "codegen",
8
+ "enums",
8
9
  "interfaces",
9
10
  "kubb",
10
- "oas",
11
11
  "openapi",
12
- "plugins",
13
12
  "swagger",
14
- "type-definitions",
15
13
  "type-generation",
16
- "type-safe",
17
14
  "types",
18
15
  "typescript"
19
16
  ],
@@ -27,7 +24,7 @@
27
24
  "files": [
28
25
  "src",
29
26
  "dist",
30
- "plugin.json",
27
+ "extension.yaml",
31
28
  "!/**/**.test.**",
32
29
  "!/**/__tests__/**",
33
30
  "!/**/__snapshots__/**"
@@ -49,17 +46,18 @@
49
46
  "registry": "https://registry.npmjs.org/"
50
47
  },
51
48
  "dependencies": {
52
- "@kubb/core": "5.0.0-beta.3",
53
- "@kubb/parser-ts": "5.0.0-beta.3",
54
- "@kubb/renderer-jsx": "5.0.0-beta.3",
55
- "remeda": "^2.34.0",
49
+ "@kubb/core": "5.0.0-beta.29",
50
+ "@kubb/parser-ts": "5.0.0-beta.29",
51
+ "@kubb/renderer-jsx": "5.0.0-beta.29",
52
+ "remeda": "^2.34.1",
56
53
  "typescript": "^6.0.3"
57
54
  },
58
55
  "devDependencies": {
56
+ "@internals/shared": "0.0.0",
59
57
  "@internals/utils": "0.0.0"
60
58
  },
61
59
  "peerDependencies": {
62
- "@kubb/renderer-jsx": "5.0.0-beta.3"
60
+ "@kubb/renderer-jsx": "5.0.0-beta.29"
63
61
  },
64
62
  "size-limit": [
65
63
  {
@@ -1,6 +1,6 @@
1
1
  import { camelCase, trimQuotes } from '@internals/utils'
2
2
  import type { ast } from '@kubb/core'
3
- import { safePrint } from '@kubb/parser-ts'
3
+ import { parserTs } from '@kubb/parser-ts'
4
4
  import { File } from '@kubb/renderer-jsx'
5
5
  import type { KubbReactNode } from '@kubb/renderer-jsx/types'
6
6
  import { ENUM_TYPES_WITH_KEY_SUFFIX, ENUM_TYPES_WITH_RUNTIME_VALUE, ENUM_TYPES_WITH_TYPE_ONLY } from '../constants.ts'
@@ -72,11 +72,11 @@ export function Enum({ node, enumType, enumTypeSuffix, enumKeyCasing, resolver }
72
72
  <>
73
73
  {nameNode && (
74
74
  <File.Source name={enumName} isExportable isIndexable isTypeOnly={false}>
75
- {safePrint(nameNode)}
75
+ {parserTs.print(nameNode)}
76
76
  </File.Source>
77
77
  )}
78
78
  <File.Source name={typeName} isIndexable isExportable={ENUM_TYPES_WITH_RUNTIME_VALUE.has(enumType)} isTypeOnly={ENUM_TYPES_WITH_TYPE_ONLY.has(enumType)}>
79
- {safePrint(typeNode)}
79
+ {parserTs.print(typeNode)}
80
80
  </File.Source>
81
81
  </>
82
82
  )
package/src/factory.ts CHANGED
@@ -27,6 +27,10 @@ export const syntaxKind = {
27
27
  stringLiteral: SyntaxKind.StringLiteral,
28
28
  } as const
29
29
 
30
+ function isNonNullable<T>(value: T | null | undefined): value is T {
31
+ return value !== null && value !== undefined
32
+ }
33
+
30
34
  function isValidIdentifier(str: string): boolean {
31
35
  if (!str.length || str.trim() !== str) {
32
36
  return false
@@ -158,7 +162,9 @@ export function createPropertySignature({
158
162
  type?: ts.TypeNode
159
163
  }) {
160
164
  return factory.createPropertySignature(
161
- [...modifiers, readOnly ? factory.createToken(ts.SyntaxKind.ReadonlyKeyword) : undefined].filter(Boolean),
165
+ [...modifiers, readOnly ? factory.createToken(ts.SyntaxKind.ReadonlyKeyword) : undefined].filter(
166
+ (modifier): modifier is ts.Modifier => modifier !== undefined,
167
+ ),
162
168
  propertyName(name),
163
169
  createQuestionToken(questionToken),
164
170
  type,
@@ -192,7 +198,7 @@ export function createParameterSignature(
192
198
  * Creates a JSDoc comment node from an array of comment strings.
193
199
  * Returns null if no comments are provided.
194
200
  */
195
- export function createJSDoc({ comments }: { comments: string[] }) {
201
+ export function createJSDoc({ comments }: { comments: Array<string> }) {
196
202
  if (!comments.length) {
197
203
  return null
198
204
  }
@@ -332,7 +338,7 @@ export function createTypeDeclaration({
332
338
  /**
333
339
  * Creates a TypeScript namespace declaration (exported module).
334
340
  */
335
- export function createNamespaceDeclaration({ statements, name }: { name: string; statements: ts.Statement[] }) {
341
+ export function createNamespaceDeclaration({ statements, name }: { name: string; statements: Array<ts.Statement> }) {
336
342
  return factory.createModuleDeclaration(
337
343
  [factory.createToken(ts.SyntaxKind.ExportKeyword)],
338
344
  factory.createIdentifier(name),
@@ -366,13 +372,8 @@ export function createImportDeclaration({
366
372
  isNameSpace?: boolean
367
373
  }) {
368
374
  if (!Array.isArray(name)) {
369
- let importPropertyName: ts.Identifier | undefined = factory.createIdentifier(name)
370
- let importName: ts.NamedImportBindings | undefined
371
-
372
- if (isNameSpace) {
373
- importPropertyName = undefined
374
- importName = factory.createNamespaceImport(factory.createIdentifier(name))
375
- }
375
+ const importPropertyName = isNameSpace ? undefined : factory.createIdentifier(name)
376
+ const importName = isNameSpace ? factory.createNamespaceImport(factory.createIdentifier(name)) : undefined
376
377
 
377
378
  return factory.createImportDeclaration(
378
379
  undefined,
@@ -518,7 +519,7 @@ export function createEnumDeclaration({
518
519
  * Enum name in PascalCase.
519
520
  */
520
521
  typeName: string
521
- enums: [key: string | number, value: string | number | boolean][]
522
+ enums: Array<[key: string | number, value: string | number | boolean]>
522
523
  /**
523
524
  * Choose the casing for enum key names.
524
525
  * @default 'none'
@@ -553,7 +554,7 @@ export function createEnumDeclaration({
553
554
 
554
555
  return undefined
555
556
  })
556
- .filter(Boolean),
557
+ .filter((node): node is ts.LiteralTypeNode => node !== undefined),
557
558
  ),
558
559
  ),
559
560
  ]
@@ -563,7 +564,9 @@ export function createEnumDeclaration({
563
564
  return [
564
565
  undefined,
565
566
  factory.createEnumDeclaration(
566
- [factory.createToken(ts.SyntaxKind.ExportKeyword), type === 'constEnum' ? factory.createToken(ts.SyntaxKind.ConstKeyword) : undefined].filter(Boolean),
567
+ [factory.createToken(ts.SyntaxKind.ExportKeyword), type === 'constEnum' ? factory.createToken(ts.SyntaxKind.ConstKeyword) : undefined].filter(
568
+ (modifier): modifier is ts.ModifierToken<ts.SyntaxKind.ExportKeyword> | ts.ModifierToken<ts.SyntaxKind.ConstKeyword> => modifier !== undefined,
569
+ ),
567
570
  factory.createIdentifier(typeName),
568
571
  enums
569
572
  .map(([key, value]) => {
@@ -594,7 +597,7 @@ export function createEnumDeclaration({
594
597
 
595
598
  return undefined
596
599
  })
597
- .filter(Boolean),
600
+ .filter((member): member is ts.EnumMember => member !== undefined),
598
601
  ),
599
602
  ]
600
603
  }
@@ -657,7 +660,7 @@ export function createEnumDeclaration({
657
660
 
658
661
  return undefined
659
662
  })
660
- .filter(Boolean),
663
+ .filter((property): property is ts.PropertyAssignment => property !== undefined),
661
664
  true,
662
665
  ),
663
666
  factory.createTypeReferenceNode(factory.createIdentifier('const'), undefined),
@@ -737,8 +740,8 @@ export function createUrlTemplateType(path: string): ts.TypeNode {
737
740
  }
738
741
 
739
742
  const segments = normalized.split(/(\{[^}]+\})/)
740
- const parts: string[] = []
741
- const parameterIndices: number[] = []
743
+ const parts: Array<string> = []
744
+ const parameterIndices: Array<number> = []
742
745
 
743
746
  segments.forEach((segment) => {
744
747
  if (segment.startsWith('{') && segment.endsWith('}')) {
@@ -750,7 +753,7 @@ export function createUrlTemplateType(path: string): ts.TypeNode {
750
753
  })
751
754
 
752
755
  const head = ts.factory.createTemplateHead(parts[0] || '')
753
- const templateSpans: ts.TemplateLiteralTypeSpan[] = []
756
+ const templateSpans: Array<ts.TemplateLiteralTypeSpan> = []
754
757
 
755
758
  parameterIndices.forEach((paramIndex, i) => {
756
759
  const isLast = i === parameterIndices.length - 1
@@ -885,7 +888,7 @@ export function buildMemberNodes(
885
888
  members: Array<ast.SchemaNode> | undefined,
886
889
  print: (node: ast.SchemaNode) => ts.TypeNode | null | undefined,
887
890
  ): Array<ts.TypeNode> {
888
- return (members ?? []).map(print).filter(Boolean)
891
+ return (members ?? []).map(print).filter(isNonNullable)
889
892
  }
890
893
 
891
894
  /**
@@ -893,7 +896,7 @@ export function buildMemberNodes(
893
896
  * applying min/max slice and optional/rest element rules.
894
897
  */
895
898
  export function buildTupleNode(node: ast.ArraySchemaNode, print: (node: ast.SchemaNode) => ts.TypeNode | null | undefined): ts.TypeNode | undefined {
896
- let items = (node.items ?? []).map(print).filter(Boolean)
899
+ let items = (node.items ?? []).map(print).filter(isNonNullable)
897
900
 
898
901
  const restNode = node.rest ? (print(node.rest) ?? undefined) : undefined
899
902
  const { min, max } = node
@@ -1,14 +1,38 @@
1
1
  import { ast, defineGenerator } from '@kubb/core'
2
- import { File, jsxRenderer } from '@kubb/renderer-jsx'
2
+ import { File, jsxRendererSync } from '@kubb/renderer-jsx'
3
3
  import { Type } from '../components/Type.tsx'
4
4
  import { ENUM_TYPES_WITH_KEY_SUFFIX } from '../constants.ts'
5
5
  import { printerTs } from '../printers/printerTs.ts'
6
6
  import type { PluginTs } from '../types'
7
7
  import { buildData, buildResponses, buildResponseUnion } from '../utils.ts'
8
8
 
9
+ function getContentTypeSuffix(contentType: string): string {
10
+ const baseType = contentType.split(';')[0]!.trim()
11
+ if (baseType === 'application/json') return 'Json'
12
+ if (baseType === 'multipart/form-data') return 'FormData'
13
+ if (baseType === 'application/x-www-form-urlencoded') return 'FormUrlEncoded'
14
+ const subtype = baseType.split('/').pop() ?? baseType
15
+ const parts = subtype.split(/[^a-zA-Z0-9]+/).filter(Boolean)
16
+ if (parts.length === 0) return 'Unknown'
17
+ return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
18
+ }
19
+
20
+ function getPerContentTypeName(dataName: string, suffix: string): string {
21
+ if (dataName.endsWith('Data')) {
22
+ return suffix.endsWith('Data') ? dataName.slice(0, -4) + suffix : `${dataName.slice(0, -4)}${suffix}Data`
23
+ }
24
+ return dataName + suffix
25
+ }
26
+
27
+ /**
28
+ * Built-in generator for `@kubb/plugin-ts`. Emits one TypeScript file per
29
+ * schema in the spec plus per-operation request, response, and parameter
30
+ * types. Drop-replace with a custom `Generator<PluginTs>` to change how
31
+ * TypeScript output is produced.
32
+ */
9
33
  export const typeGenerator = defineGenerator<PluginTs>({
10
34
  name: 'typescript',
11
- renderer: jsxRenderer,
35
+ renderer: jsxRendererSync,
12
36
  schema(node, ctx) {
13
37
  const { enumType, enumTypeSuffix, enumKeyCasing, syntaxType, optionalType, arrayType, output, group, printer } = ctx.options
14
38
  const { adapter, config, resolver, root } = ctx
@@ -19,7 +43,7 @@ export const typeGenerator = defineGenerator<PluginTs>({
19
43
  const mode = ctx.getMode(output)
20
44
  // Build a set of schema names that are enums so the ref handler and getImports
21
45
  // 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!))
46
+ const enumSchemaNames = new Set<string>(ctx.meta.enumNames)
23
47
 
24
48
  function resolveImportName(schemaName: string): string {
25
49
  if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && enumTypeSuffix && enumSchemaNames.has(schemaName)) {
@@ -30,14 +54,14 @@ export const typeGenerator = defineGenerator<PluginTs>({
30
54
 
31
55
  const imports = adapter.getImports(node, (schemaName) => ({
32
56
  name: resolveImportName(schemaName),
33
- path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
57
+ path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
34
58
  }))
35
59
 
36
60
  const isEnumSchema = !!ast.narrowSchema(node, ast.schemaTypes.enum)
37
61
 
38
62
  const meta = {
39
63
  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 }),
64
+ file: resolver.resolveFile({ name: node.name, extname: '.ts' }, { root, output, group: group ?? undefined }),
41
65
  } as const
42
66
 
43
67
  const schemaPrinter = printerTs({
@@ -58,8 +82,8 @@ export const typeGenerator = defineGenerator<PluginTs>({
58
82
  baseName={meta.file.baseName}
59
83
  path={meta.file.path}
60
84
  meta={meta.file.meta}
61
- banner={resolver.resolveBanner(adapter.inputNode, { output, config })}
62
- footer={resolver.resolveFooter(adapter.inputNode, { output, config })}
85
+ banner={resolver.resolveBanner(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
86
+ footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
63
87
  >
64
88
  {mode === 'split' &&
65
89
  imports.map((imp) => (
@@ -86,12 +110,15 @@ export const typeGenerator = defineGenerator<PluginTs>({
86
110
  const params = ast.caseParams(node.parameters, paramsCasing)
87
111
 
88
112
  const meta = {
89
- file: resolver.resolveFile({ name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path }, { root, output, group }),
113
+ file: resolver.resolveFile(
114
+ { name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path },
115
+ { root, output, group: group ?? undefined },
116
+ ),
90
117
  } as const
91
118
 
92
119
  // Build a set of schema names that are enums so the ref handler and getImports
93
120
  // 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!))
121
+ const enumSchemaNames = new Set<string>(ctx.meta.enumNames)
95
122
 
96
123
  function resolveImportName(schemaName: string): string {
97
124
  if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && enumTypeSuffix && enumSchemaNames.has(schemaName)) {
@@ -100,12 +127,12 @@ export const typeGenerator = defineGenerator<PluginTs>({
100
127
  return resolver.resolveTypeName(schemaName)
101
128
  }
102
129
 
103
- function renderSchemaType({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array<string> }) {
130
+ function renderSchemaType({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array<string> | null }) {
104
131
  if (!schema) return null
105
132
 
106
133
  const imports = adapter.getImports(schema, (schemaName) => ({
107
134
  name: resolveImportName(schemaName),
108
- path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
135
+ path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
109
136
  }))
110
137
 
111
138
  const schemaPrinter = printerTs({
@@ -148,22 +175,67 @@ export const typeGenerator = defineGenerator<PluginTs>({
148
175
  }),
149
176
  )
150
177
 
151
- const requestType = node.requestBody?.content?.[0]?.schema
152
- ? renderSchemaType({
178
+ const requestBodyContent = node.requestBody?.content ?? []
179
+
180
+ function buildRequestType() {
181
+ if (requestBodyContent.length === 0) return null
182
+ if (requestBodyContent.length === 1) {
183
+ const entry = requestBodyContent[0]!
184
+ if (!entry.schema) return null
185
+ return renderSchemaType({
153
186
  schema: {
154
- ...node.requestBody.content![0]!.schema!,
155
- description: node.requestBody.description ?? node.requestBody.content![0]!.schema!.description,
187
+ ...entry.schema,
188
+ description: node.requestBody!.description ?? entry.schema.description,
156
189
  },
157
190
  name: resolver.resolveDataName(node),
158
- keysToOmit: node.requestBody.content![0]!.keysToOmit,
191
+ keysToOmit: entry.keysToOmit,
159
192
  })
160
- : null
193
+ }
194
+ // Multiple content types — generate individual types + union alias
195
+ const dataName = resolver.resolveDataName(node)
196
+ const usedNames = new Set<string>()
197
+ const individualItems = requestBodyContent
198
+ .filter((entry) => entry.schema)
199
+ .map((entry) => {
200
+ const baseSuffix = getContentTypeSuffix(entry.contentType)
201
+ let individualName = getPerContentTypeName(dataName, baseSuffix)
202
+ let counter = 2
203
+ while (usedNames.has(individualName)) {
204
+ individualName = getPerContentTypeName(dataName, `${baseSuffix}${counter++}`)
205
+ }
206
+ usedNames.add(individualName)
207
+ return {
208
+ name: individualName,
209
+ rendered: renderSchemaType({
210
+ schema: {
211
+ ...entry.schema!,
212
+ description: node.requestBody!.description ?? entry.schema!.description,
213
+ },
214
+ name: individualName,
215
+ keysToOmit: entry.keysToOmit,
216
+ }),
217
+ }
218
+ })
219
+ const unionSchema = ast.createSchema({
220
+ type: 'union',
221
+ members: individualItems.map((item) => ast.createSchema({ type: 'ref', name: item.name })),
222
+ })
223
+ const unionType = renderSchemaType({ schema: unionSchema, name: dataName })
224
+ return (
225
+ <>
226
+ {individualItems.map((item) => item.rendered)}
227
+ {unionType}
228
+ </>
229
+ )
230
+ }
231
+
232
+ const requestType = buildRequestType()
161
233
 
162
234
  const responseTypes = node.responses.map((res) =>
163
235
  renderSchemaType({
164
- schema: res.schema,
236
+ schema: res.content?.[0]?.schema ?? null,
165
237
  name: resolver.resolveResponseStatusName(node, res.statusCode),
166
- keysToOmit: res.keysToOmit,
238
+ keysToOmit: res.content?.[0]?.keysToOmit,
167
239
  }),
168
240
  )
169
241
 
@@ -177,21 +249,19 @@ export const typeGenerator = defineGenerator<PluginTs>({
177
249
  name: resolver.resolveResponsesName(node),
178
250
  })
179
251
 
180
- const responseType = (() => {
181
- if (!node.responses.some((res) => res.schema)) {
252
+ function buildResponseType() {
253
+ if (!node.responses.some((res) => res.content?.[0]?.schema)) {
182
254
  return null
183
255
  }
184
256
 
185
257
  const responseName = resolver.resolveResponseName(node)
186
258
 
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)
259
+ const responsesWithSchema = node.responses.filter((res) => res.content?.[0]?.schema)
190
260
  const importedNames = new Set(
191
261
  responsesWithSchema.flatMap((res) =>
192
- res.schema
262
+ res.content?.[0]?.schema
193
263
  ? adapter
194
- .getImports(res.schema, (schemaName) => ({
264
+ .getImports(res.content[0].schema, (schemaName) => ({
195
265
  name: resolveImportName(schemaName),
196
266
  path: '',
197
267
  }))
@@ -211,15 +281,17 @@ export const typeGenerator = defineGenerator<PluginTs>({
211
281
  },
212
282
  name: responseName,
213
283
  })
214
- })()
284
+ }
285
+
286
+ const responseType = buildResponseType()
215
287
 
216
288
  return (
217
289
  <File
218
290
  baseName={meta.file.baseName}
219
291
  path={meta.file.path}
220
292
  meta={meta.file.meta}
221
- banner={resolver.resolveBanner(adapter.inputNode, { output, config })}
222
- footer={resolver.resolveFooter(adapter.inputNode, { output, config })}
293
+ banner={resolver.resolveBanner(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
294
+ footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
223
295
  >
224
296
  {paramTypes}
225
297
  {responseTypes}
package/src/plugin.ts CHANGED
@@ -5,23 +5,32 @@ 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
+ * enumType: 'asConst',
31
+ * optionalType: 'questionTokenAndUndefined',
32
+ * }),
33
+ * ],
25
34
  * })
26
35
  * ```
27
36
  */
@@ -55,7 +64,7 @@ export const pluginTs = definePlugin<PluginTs>((options) => {
55
64
  return `${camelCase(ctx.group)}Controller`
56
65
  },
57
66
  } satisfies Group)
58
- : undefined
67
+ : null
59
68
 
60
69
  return {
61
70
  name: pluginTsName,
@@ -1,11 +1,15 @@
1
1
  import { ast } from '@kubb/core'
2
- import { safePrint } from '@kubb/parser-ts'
2
+ import { parserTs } from '@kubb/parser-ts'
3
3
  import type ts from 'typescript'
4
4
  import { ENUM_TYPES_WITH_KEY_SUFFIX, OPTIONAL_ADDS_QUESTION_TOKEN, OPTIONAL_ADDS_UNDEFINED } from '../constants.ts'
5
5
  import * as factory from '../factory.ts'
6
6
  import type { PluginTs, ResolverTs } from '../types.ts'
7
7
  import { buildPropertyJSDocComments } from '../utils.ts'
8
8
 
9
+ function isNonNullable<T>(value: T | null | undefined): value is T {
10
+ return value !== null && value !== undefined
11
+ }
12
+
9
13
  /**
10
14
  * Partial map of node-type overrides for the TypeScript printer.
11
15
  *
@@ -85,7 +89,7 @@ export type PrinterTsOptions = {
85
89
  * Properties to exclude using `Omit<Type, Keys>`.
86
90
  * Forces type alias syntax regardless of `syntaxType` setting.
87
91
  */
88
- keysToOmit?: Array<string>
92
+ keysToOmit?: Array<string> | null
89
93
  /**
90
94
  * Transforms raw schema names into valid TypeScript identifiers.
91
95
  */
@@ -163,7 +167,7 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
163
167
  time: factory.dateOrStringNode,
164
168
  ref(node) {
165
169
  if (!node.name) {
166
- return undefined
170
+ return null
167
171
  }
168
172
  // Parser-generated refs (with $ref) carry raw schema names that need resolving.
169
173
  // Use the canonical name from the $ref path — node.name may have been overridden
@@ -191,7 +195,7 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
191
195
  const literalNodes = values
192
196
  .filter((v): v is string | number | boolean => v !== null && v !== undefined)
193
197
  .map((value) => factory.constToTypeNode(value, typeof value as 'string' | 'number' | 'boolean'))
194
- .filter(Boolean)
198
+ .filter(isNonNullable)
195
199
 
196
200
  return factory.createUnionDeclaration({ withParentheses: true, nodes: literalNodes }) ?? undefined
197
201
  }
@@ -224,7 +228,7 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
224
228
 
225
229
  return this.transform(m)
226
230
  })
227
- .filter(Boolean)
231
+ .filter(isNonNullable)
228
232
 
229
233
  return factory.createUnionDeclaration({ withParentheses: true, nodes: memberNodes }) ?? undefined
230
234
  }
@@ -232,15 +236,15 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
232
236
  return factory.createUnionDeclaration({ withParentheses: true, nodes: factory.buildMemberNodes(members, this.transform) }) ?? undefined
233
237
  },
234
238
  intersection(node) {
235
- return factory.createIntersectionDeclaration({ withParentheses: true, nodes: factory.buildMemberNodes(node.members, this.transform) }) ?? undefined
239
+ return factory.createIntersectionDeclaration({ withParentheses: true, nodes: factory.buildMemberNodes(node.members, this.transform) }) ?? null
236
240
  },
237
241
  array(node) {
238
- const itemNodes = (node.items ?? []).map((item) => this.transform(item)).filter(Boolean)
242
+ const itemNodes = (node.items ?? []).map((item) => this.transform(item)).filter(isNonNullable)
239
243
 
240
- return factory.createArrayDeclaration({ nodes: itemNodes, arrayType: this.options.arrayType }) ?? undefined
244
+ return factory.createArrayDeclaration({ nodes: itemNodes, arrayType: this.options.arrayType }) ?? null
241
245
  },
242
246
  tuple(node) {
243
- return factory.buildTupleNode(node, this.transform)
247
+ return factory.buildTupleNode(node, this.transform) ?? null
244
248
  },
245
249
  object(node) {
246
250
  const { transform, options } = this
@@ -275,36 +279,33 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
275
279
  print(node) {
276
280
  const { name, syntaxType = 'type', description, keysToOmit } = this.options
277
281
 
278
- let base = this.transform(node)
279
- if (!base) return null
282
+ const transformed = this.transform(node)
283
+ if (!transformed) return null
280
284
 
281
285
  // For ref nodes, structural metadata lives on node.schema rather than the ref node itself.
282
286
  const meta = ast.syncSchemaRef(node)
283
287
 
284
288
  // Without name, apply modifiers inline and return.
285
289
  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)
290
+ const withNullable = meta.nullable ? factory.createUnionDeclaration({ nodes: [transformed, factory.keywordTypeNodes.null] }) : transformed
291
+ const result =
292
+ (meta.nullish || meta.optional) && addsUndefined
293
+ ? factory.createUnionDeclaration({ nodes: [withNullable, factory.keywordTypeNodes.undefined] })
294
+ : withNullable
295
+ return parserTs.print(result)
293
296
  }
294
297
 
295
298
  // When keysToOmit is present, wrap with Omit first, then apply nullable/optional
296
299
  // 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
- }
300
+ const inner = (() => {
301
+ const omitted: ts.TypeNode = keysToOmit?.length
302
+ ? factory.createOmitDeclaration({ keys: keysToOmit, type: transformed, nonNullable: true })
303
+ : transformed
304
+ const withNullable = meta.nullable ? factory.createUnionDeclaration({ nodes: [omitted, factory.keywordTypeNodes.null] }) : omitted
305
+ // For named type declarations (type aliases), optional/nullish always produces | undefined
306
+ // regardless of optionalType the questionToken ? modifier only applies to object properties.
307
+ return meta.nullish || meta.optional ? factory.createUnionDeclaration({ nodes: [withNullable, factory.keywordTypeNodes.undefined] }) : withNullable
308
+ })()
308
309
 
309
310
  const useTypeGeneration = syntaxType === 'type' || inner.kind === factory.syntaxKind.union || !!keysToOmit?.length
310
311
 
@@ -319,7 +320,7 @@ export const printerTs = ast.definePrinter<PrinterTs>((options) => {
319
320
  }),
320
321
  })
321
322
 
322
- return safePrint(typeNode)
323
+ return parserTs.print(typeNode)
323
324
  },
324
325
  }
325
326
  })