@kubb/plugin-zod 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,20 +1,16 @@
1
1
  {
2
2
  "name": "@kubb/plugin-zod",
3
- "version": "5.0.0-beta.3",
4
- "description": "Zod schema generator plugin for Kubb, creating type-safe validation schemas from OpenAPI specifications for runtime data validation.",
3
+ "version": "5.0.0-beta.30",
4
+ "description": "Generate Zod validation schemas from your OpenAPI specification for runtime data parsing and type safety. Pairs perfectly with @kubb/plugin-ts for end-to-end type coverage.",
5
5
  "keywords": [
6
- "code-generator",
6
+ "code-generation",
7
7
  "codegen",
8
8
  "kubb",
9
- "oas",
10
9
  "openapi",
11
- "plugins",
10
+ "parse",
12
11
  "runtime-validation",
13
12
  "schema",
14
- "schema-validation",
15
13
  "swagger",
16
- "type-safe",
17
- "type-safety",
18
14
  "typescript",
19
15
  "validation",
20
16
  "zod"
@@ -30,7 +26,7 @@
30
26
  "src",
31
27
  "templates",
32
28
  "dist",
33
- "plugin.json",
29
+ "extension.yaml",
34
30
  "!/**/**.test.**",
35
31
  "!/**/__tests__/**",
36
32
  "!/**/__snapshots__/**"
@@ -52,15 +48,15 @@
52
48
  "registry": "https://registry.npmjs.org/"
53
49
  },
54
50
  "dependencies": {
55
- "@kubb/core": "5.0.0-beta.3",
56
- "@kubb/renderer-jsx": "5.0.0-beta.3",
57
- "remeda": "^2.34.0"
51
+ "@kubb/core": "5.0.0-beta.29",
52
+ "@kubb/renderer-jsx": "5.0.0-beta.29",
53
+ "remeda": "^2.34.1"
58
54
  },
59
55
  "devDependencies": {
60
56
  "@internals/utils": "0.0.0"
61
57
  },
62
58
  "peerDependencies": {
63
- "@kubb/renderer-jsx": "5.0.0-beta.3"
59
+ "@kubb/renderer-jsx": "5.0.0-beta.29"
64
60
  },
65
61
  "size-limit": [
66
62
  {
@@ -4,11 +4,11 @@ import { Const, File, Type } from '@kubb/renderer-jsx'
4
4
  import type { KubbReactNode } from '@kubb/renderer-jsx/types'
5
5
 
6
6
  type SchemaNames = {
7
- request: string | undefined
7
+ request: string | null
8
8
  parameters: {
9
- path: string | undefined
10
- query: string | undefined
11
- header: string | undefined
9
+ path: string | null
10
+ query: string | null
11
+ header: string | null
12
12
  }
13
13
  responses: { default?: string } & Record<number | string, string>
14
14
  errors: Record<number | string, string>
@@ -13,7 +13,7 @@ type Props = {
13
13
  * then merges in any user-supplied `printer.nodes` overrides.
14
14
  */
15
15
  printer: ast.Printer<PrinterZodFactory> | ast.Printer<PrinterZodMiniFactory>
16
- inferTypeName?: string
16
+ inferTypeName?: string | null
17
17
  }
18
18
 
19
19
  export function Zod({ name, node, printer, inferTypeName }: Props): KubbReactNode {
@@ -1,18 +1,31 @@
1
1
  import type { Adapter } from '@kubb/core'
2
2
  import { ast, defineGenerator } from '@kubb/core'
3
3
  import type { AdapterOas } from '@kubb/adapter-oas'
4
- import { File, jsxRenderer } from '@kubb/renderer-jsx'
4
+ import { File, jsxRendererSync } from '@kubb/renderer-jsx'
5
5
  import { Operations } from '../components/Operations.tsx'
6
6
  import { Zod } from '../components/Zod.tsx'
7
7
  import { ZOD_NAMESPACE_IMPORTS } from '../constants.ts'
8
8
  import { printerZod } from '../printers/printerZod.ts'
9
9
  import { printerZodMini } from '../printers/printerZodMini.ts'
10
- import type { PluginZod } from '../types'
10
+ import type { PluginZod, ResolverZod } from '../types'
11
11
  import { buildSchemaNames } from '../utils.ts'
12
12
 
13
+ type ZodPrinterEntry = { printer: ReturnType<typeof printerZod>; coercion: unknown; guidType: unknown; dateType: unknown }
14
+ type ZodMiniPrinterEntry = { printer: ReturnType<typeof printerZodMini>; guidType: unknown }
15
+
16
+ // Per-build caches: keyed on resolver (unique per plugin instance per build, GC'd when released)
17
+ const zodPrinterCache = new WeakMap<ResolverZod, ZodPrinterEntry>()
18
+ const zodMiniPrinterCache = new WeakMap<ResolverZod, ZodMiniPrinterEntry>()
19
+
20
+ /**
21
+ * Built-in generator for `@kubb/plugin-zod`. Emits one Zod schema per
22
+ * schema in the spec plus per-operation request/response/parameter schemas.
23
+ * When `mini: true`, schemas use the Zod Mini functional API instead of
24
+ * chainable methods.
25
+ */
13
26
  export const zodGenerator = defineGenerator<PluginZod>({
14
27
  name: 'zod',
15
- renderer: jsxRenderer,
28
+ renderer: jsxRendererSync,
16
29
  schema(node, ctx) {
17
30
  const { adapter, config, resolver, root } = ctx
18
31
  const { output, coercion, guidType, mini, wrapOutput, inferred, importPath, group, printer } = ctx.options
@@ -27,29 +40,45 @@ export const zodGenerator = defineGenerator<PluginZod>({
27
40
 
28
41
  const imports = adapter.getImports(node, (schemaName) => ({
29
42
  name: resolver.resolveSchemaName(schemaName),
30
- path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
43
+ path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
31
44
  }))
32
45
 
33
46
  const meta = {
34
47
  name: resolver.resolveSchemaName(node.name),
35
- file: resolver.resolveFile({ name: node.name, extname: '.ts' }, { root, output, group }),
48
+ file: resolver.resolveFile({ name: node.name, extname: '.ts' }, { root, output, group: group ?? undefined }),
36
49
  } as const
37
50
 
38
- const inferTypeName = inferred ? resolver.resolveSchemaTypeName(node.name) : undefined
51
+ const inferTypeName = inferred ? resolver.resolveSchemaTypeName(node.name) : null
39
52
 
40
- const cyclicSchemas = adapter.inputNode ? ast.findCircularSchemas(adapter.inputNode.schemas) : undefined
53
+ const cyclicSchemas = new Set<string>(ctx.meta.circularNames)
41
54
 
42
- const schemaPrinter = mini
43
- ? printerZodMini({ guidType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
44
- : printerZod({ coercion, guidType, dateType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
55
+ const schemaPrinter = mini ? getCachedMiniPrinter() : getCachedStdPrinter()
56
+ function getCachedStdPrinter() {
57
+ const cached = zodPrinterCache.get(resolver)
58
+ if (cached && cached.coercion === coercion && cached.guidType === guidType && cached.dateType === dateType) {
59
+ return cached.printer
60
+ }
61
+ const p = printerZod({ coercion, guidType, dateType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
62
+ zodPrinterCache.set(resolver, { printer: p, coercion, guidType, dateType })
63
+ return p
64
+ }
65
+ function getCachedMiniPrinter() {
66
+ const cached = zodMiniPrinterCache.get(resolver)
67
+ if (cached && cached.guidType === guidType) {
68
+ return cached.printer
69
+ }
70
+ const p = printerZodMini({ guidType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
71
+ zodMiniPrinterCache.set(resolver, { printer: p, guidType })
72
+ return p
73
+ }
45
74
 
46
75
  return (
47
76
  <File
48
77
  baseName={meta.file.baseName}
49
78
  path={meta.file.path}
50
79
  meta={meta.file.meta}
51
- banner={resolver.resolveBanner(adapter.inputNode, { output, config })}
52
- footer={resolver.resolveFooter(adapter.inputNode, { output, config })}
80
+ banner={resolver.resolveBanner(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
81
+ footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
53
82
  >
54
83
  <File.Import name={isZodImport ? 'z' : ['z']} path={importPath} isNameSpace={isZodImport} />
55
84
  {mode === 'split' && imports.map((imp) => <File.Import key={[node.name, imp.path].join('-')} root={meta.file.path} path={imp.path} name={imp.name} />)}
@@ -69,24 +98,37 @@ export const zodGenerator = defineGenerator<PluginZod>({
69
98
  const params = ast.caseParams(node.parameters, paramsCasing)
70
99
 
71
100
  const meta = {
72
- file: resolver.resolveFile({ name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path }, { root, output, group }),
101
+ file: resolver.resolveFile(
102
+ { name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path },
103
+ { root, output, group: group ?? undefined },
104
+ ),
73
105
  } as const
74
106
 
75
- const cyclicSchemas = adapter.inputNode ? ast.findCircularSchemas(adapter.inputNode.schemas) : undefined
107
+ const cyclicSchemas = new Set<string>(ctx.meta.circularNames)
76
108
 
77
- function renderSchemaEntry({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array<string> }) {
109
+ function renderSchemaEntry({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array<string> | null }) {
78
110
  if (!schema) return null
79
111
 
80
- const inferTypeName = inferred ? resolver.resolveTypeName(name) : undefined
112
+ const inferTypeName = inferred ? resolver.resolveTypeName(name) : null
81
113
 
82
114
  const imports = adapter.getImports(schema, (schemaName) => ({
83
115
  name: resolver.resolveSchemaName(schemaName),
84
- path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
116
+ path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
85
117
  }))
86
118
 
119
+ const cachedStd = zodPrinterCache.get(resolver)
120
+ const cachedMini = zodMiniPrinterCache.get(resolver)
87
121
  const schemaPrinter = mini
88
- ? printerZodMini({ guidType, wrapOutput, resolver, keysToOmit, cyclicSchemas, nodes: printer?.nodes })
89
- : printerZod({ coercion, guidType, dateType, wrapOutput, resolver, keysToOmit, cyclicSchemas, nodes: printer?.nodes })
122
+ ? keysToOmit?.length
123
+ ? printerZodMini({ guidType, wrapOutput, resolver, keysToOmit, cyclicSchemas, nodes: printer?.nodes })
124
+ : cachedMini?.guidType === guidType
125
+ ? cachedMini.printer
126
+ : printerZodMini({ guidType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
127
+ : keysToOmit?.length
128
+ ? printerZod({ coercion, guidType, dateType, wrapOutput, resolver, keysToOmit, cyclicSchemas, nodes: printer?.nodes })
129
+ : cachedStd?.coercion === coercion && cachedStd?.guidType === guidType && cachedStd?.dateType === dateType
130
+ ? cachedStd.printer
131
+ : printerZod({ coercion, guidType, dateType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
90
132
 
91
133
  return (
92
134
  <>
@@ -101,13 +143,13 @@ export const zodGenerator = defineGenerator<PluginZod>({
101
143
 
102
144
  const responseSchemas = node.responses.map((res) =>
103
145
  renderSchemaEntry({
104
- schema: res.schema,
146
+ schema: res.content?.[0]?.schema ?? null,
105
147
  name: resolver.resolveResponseStatusName(node, res.statusCode),
106
- keysToOmit: res.keysToOmit,
148
+ keysToOmit: res.content?.[0]?.keysToOmit,
107
149
  }),
108
150
  )
109
151
 
110
- const responsesWithSchema = node.responses.filter((res) => res.schema)
152
+ const responsesWithSchema = node.responses.filter((res) => res.content?.[0]?.schema)
111
153
  const responseUnionSchema =
112
154
  responsesWithSchema.length > 0
113
155
  ? (() => {
@@ -118,9 +160,9 @@ export const zodGenerator = defineGenerator<PluginZod>({
118
160
  // the response union name, skip generation to avoid redeclaration errors.
119
161
  const importedNames = new Set(
120
162
  responsesWithSchema.flatMap((res) =>
121
- res.schema
163
+ res.content?.[0]?.schema
122
164
  ? adapter
123
- .getImports(res.schema, (schemaName) => ({
165
+ .getImports(res.content[0].schema, (schemaName) => ({
124
166
  name: resolver.resolveSchemaName(schemaName),
125
167
  path: '',
126
168
  }))
@@ -159,8 +201,8 @@ export const zodGenerator = defineGenerator<PluginZod>({
159
201
  baseName={meta.file.baseName}
160
202
  path={meta.file.path}
161
203
  meta={meta.file.meta}
162
- banner={resolver.resolveBanner(adapter.inputNode, { output, config })}
163
- footer={resolver.resolveFooter(adapter.inputNode, { output, config })}
204
+ banner={resolver.resolveBanner(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
205
+ footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
164
206
  >
165
207
  <File.Import name={isZodImport ? 'z' : ['z']} path={importPath} isNameSpace={isZodImport} />
166
208
  {paramSchemas}
@@ -171,7 +213,7 @@ export const zodGenerator = defineGenerator<PluginZod>({
171
213
  )
172
214
  },
173
215
  operations(nodes, ctx) {
174
- const { adapter, config, resolver, root } = ctx
216
+ const { config, resolver, root } = ctx
175
217
  const { output, importPath, group, operations, paramsCasing } = ctx.options
176
218
 
177
219
  if (!operations) {
@@ -180,7 +222,7 @@ export const zodGenerator = defineGenerator<PluginZod>({
180
222
  const isZodImport = ZOD_NAMESPACE_IMPORTS.has(importPath as 'zod' | 'zod/mini')
181
223
 
182
224
  const meta = {
183
- file: resolver.resolveFile({ name: 'operations', extname: '.ts' }, { root, output, group }),
225
+ file: resolver.resolveFile({ name: 'operations', extname: '.ts' }, { root, output, group: group ?? undefined }),
184
226
  } as const
185
227
 
186
228
  const transformedOperations = nodes.map((node) => {
@@ -193,8 +235,11 @@ export const zodGenerator = defineGenerator<PluginZod>({
193
235
  })
194
236
 
195
237
  const imports = transformedOperations.flatMap(({ node, data }) => {
196
- const names = [data.request, ...Object.values(data.responses), ...Object.values(data.parameters)].filter(Boolean) as string[]
197
- const opFile = resolver.resolveFile({ name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path }, { root, output, group })
238
+ const names = [data.request, ...Object.values(data.responses), ...Object.values(data.parameters)].filter(Boolean) as Array<string>
239
+ const opFile = resolver.resolveFile(
240
+ { name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path },
241
+ { root, output, group: group ?? undefined },
242
+ )
198
243
 
199
244
  return names.map((name) => <File.Import key={[name, opFile.path].join('-')} name={[name]} root={meta.file.path} path={opFile.path} />)
200
245
  })
@@ -204,8 +249,8 @@ export const zodGenerator = defineGenerator<PluginZod>({
204
249
  baseName={meta.file.baseName}
205
250
  path={meta.file.path}
206
251
  meta={meta.file.meta}
207
- banner={resolver.resolveBanner(adapter.inputNode, { output, config })}
208
- footer={resolver.resolveFooter(adapter.inputNode, { output, config })}
252
+ banner={resolver.resolveBanner(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
253
+ footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
209
254
  >
210
255
  <File.Import isTypeOnly name={isZodImport ? 'z' : ['z']} path={importPath} isNameSpace={isZodImport} />
211
256
  {imports}
package/src/plugin.ts CHANGED
@@ -5,20 +5,33 @@ import { resolverZod } from './resolvers/resolverZod.ts'
5
5
  import type { PluginZod } from './types.ts'
6
6
 
7
7
  /**
8
- * Canonical plugin name for `@kubb/plugin-zod`, used in driver lookups and warnings.
8
+ * Canonical plugin name for `@kubb/plugin-zod`. Used for driver lookups and
9
+ * cross-plugin dependency references.
9
10
  */
10
11
  export const pluginZodName = 'plugin-zod' satisfies PluginZod['name']
11
12
 
12
13
  /**
13
- * Generates Zod validation schemas from an OpenAPI specification.
14
- * Walks schemas and operations, delegates to generators, and writes barrel files
15
- * based on the configured `barrelType`.
14
+ * Generates Zod v4 schemas from an OpenAPI spec. Use them to validate API
15
+ * responses at runtime, build form schemas, or feed back into router libraries
16
+ * that consume Zod (tRPC, Hono, Elysia). Pair with `@kubb/plugin-client` and
17
+ * set the client's `parser: 'zod'` to validate every response automatically.
16
18
  *
17
- * @example Zod schema generator
19
+ * @example
18
20
  * ```ts
19
- * import pluginZod from '@kubb/plugin-zod'
21
+ * import { defineConfig } from 'kubb'
22
+ * import { pluginTs } from '@kubb/plugin-ts'
23
+ * import { pluginZod } from '@kubb/plugin-zod'
24
+ *
20
25
  * export default defineConfig({
21
- * plugins: [pluginZod({ output: { path: 'zod' } })]
26
+ * input: { path: './petStore.yaml' },
27
+ * output: { path: './src/gen' },
28
+ * plugins: [
29
+ * pluginTs(),
30
+ * pluginZod({
31
+ * output: { path: './zod' },
32
+ * typed: true,
33
+ * }),
34
+ * ],
22
35
  * })
23
36
  * ```
24
37
  */
@@ -54,7 +67,7 @@ export const pluginZod = definePlugin<PluginZod>((options) => {
54
67
  return `${camelCase(ctx.group)}Controller`
55
68
  },
56
69
  } satisfies Group)
57
- : undefined
70
+ : null
58
71
 
59
72
  return {
60
73
  name: pluginZodName,
@@ -53,7 +53,7 @@ export type PrinterZodOptions = {
53
53
  /**
54
54
  * Properties to exclude using `.omit({ key: true })`.
55
55
  */
56
- keysToOmit?: Array<string>
56
+ keysToOmit?: Array<string> | null
57
57
  /**
58
58
  * Schema names that form circular dependency chains.
59
59
  * Properties referencing these emit lazy getters wrapping refs in `z.lazy(() => …)`.
@@ -70,6 +70,33 @@ export type PrinterZodOptions = {
70
70
  */
71
71
  export type PrinterZodFactory = ast.PrinterFactoryOptions<'zod', PrinterZodOptions, string, string>
72
72
 
73
+ function strictOneOfMember(member: string, node: ast.SchemaNode): string {
74
+ if (node.type === 'object' && node.additionalProperties === undefined) {
75
+ return `${member}.strict()`
76
+ }
77
+
78
+ if (node.type === 'ref') {
79
+ if (member.startsWith('z.lazy(')) {
80
+ return member
81
+ }
82
+
83
+ const schema = ast.syncSchemaRef(node)
84
+
85
+ if (schema.type === 'object' && (schema.additionalProperties === undefined || schema.additionalProperties === false)) {
86
+ return `${member}.strict()`
87
+ }
88
+ }
89
+
90
+ return member
91
+ }
92
+
93
+ function getMemberConstraint(member: ast.SchemaNode): string | undefined {
94
+ if (member.primitive === 'string') return lengthConstraints(ast.narrowSchema(member, 'string') ?? {}) || undefined
95
+ if (member.primitive === 'number' || member.primitive === 'integer')
96
+ return numberConstraints(ast.narrowSchema(member, 'number') ?? ast.narrowSchema(member, 'integer') ?? {}) || undefined
97
+ if (member.primitive === 'array') return lengthConstraints(ast.narrowSchema(member, 'array') ?? {}) || undefined
98
+ }
99
+
73
100
  /**
74
101
  * Zod v4 printer built with `definePrinter`.
75
102
  *
@@ -164,7 +191,7 @@ export const printerZod = ast.definePrinter<PrinterZodFactory>((options) => {
164
191
  return `z.enum([${nonNullValues.map(formatLiteral).join(', ')}])`
165
192
  },
166
193
  ref(node) {
167
- if (!node.name) return undefined
194
+ if (!node.name) return null
168
195
  const refName = node.ref ? (ast.extractRefName(node.ref) ?? node.name) : node.name
169
196
  const resolvedName = node.ref ? (this.options.resolver?.default(refName, 'function') ?? refName) : node.name
170
197
 
@@ -188,19 +215,19 @@ export const printerZod = ast.definePrinter<PrinterZodFactory>((options) => {
188
215
  const hasSelfRef = this.options.cyclicSchemas != null && ast.containsCircularRef(schema, { circularSchemas: this.options.cyclicSchemas })
189
216
  // Inside a getter the getter itself defers evaluation, so suppress
190
217
  // z.lazy() wrapping on nested refs by temporarily clearing cyclicSchemas.
218
+ // Save before clearing: this.options === options (same reference via definePrinter),
219
+ // so reading options.cyclicSchemas after mutation would return undefined.
220
+ const savedCyclicSchemas = this.options.cyclicSchemas
191
221
  if (hasSelfRef) this.options.cyclicSchemas = undefined
192
222
  const baseOutput = this.transform(schema) ?? this.transform(ast.createSchema({ type: 'unknown' }))!
193
- if (hasSelfRef) this.options.cyclicSchemas = options.cyclicSchemas
223
+ if (hasSelfRef) this.options.cyclicSchemas = savedCyclicSchemas
194
224
 
195
225
  const wrappedOutput = this.options.wrapOutput ? this.options.wrapOutput({ output: baseOutput, schema }) || baseOutput : baseOutput
196
226
 
197
227
  // When a property schema is not a ref but the metadata is from a ref (e.g., discriminator
198
228
  // property override), skip applying the description from the ref target to avoid applying
199
229
  // metadata from a replaced schema.
200
- let descriptionToApply = meta.description
201
- if (schema.type !== 'ref' && meta.type === 'ref') {
202
- descriptionToApply = undefined
203
- }
230
+ const descriptionToApply = schema.type !== 'ref' && meta.type === 'ref' ? undefined : meta.description
204
231
 
205
232
  const value = applyModifiers({
206
233
  value: wrappedOutput,
@@ -218,32 +245,27 @@ export const printerZod = ast.definePrinter<PrinterZodFactory>((options) => {
218
245
  })
219
246
  .join(',\n ')
220
247
 
221
- let result = `z.object({\n ${properties}\n })`
248
+ const objectBase = `z.object({\n ${properties}\n })`
222
249
 
223
250
  // Handle additionalProperties as .catchall() or .strict()
224
- if (node.additionalProperties && node.additionalProperties !== true) {
225
- const catchallType = this.transform(node.additionalProperties)
226
- if (catchallType) {
227
- result += `.catchall(${catchallType})`
251
+ const result = (() => {
252
+ if (node.additionalProperties && node.additionalProperties !== true) {
253
+ const catchallType = this.transform(node.additionalProperties)
254
+ return catchallType ? `${objectBase}.catchall(${catchallType})` : objectBase
228
255
  }
229
- } else if (node.additionalProperties === true) {
230
- result += `.catchall(${this.transform(ast.createSchema({ type: 'unknown' }))})`
231
- } else if (node.additionalProperties === false) {
232
- result += '.strict()'
233
- }
256
+ if (node.additionalProperties === true) return `${objectBase}.catchall(${this.transform(ast.createSchema({ type: 'unknown' }))})`
257
+ if (node.additionalProperties === false) return `${objectBase}.strict()`
258
+ return objectBase
259
+ })()
234
260
 
235
261
  return result
236
262
  },
237
263
  array(node) {
238
264
  const items = (node.items ?? []).map((item) => this.transform(item)).filter(Boolean)
239
265
  const inner = items.join(', ') || this.transform(ast.createSchema({ type: 'unknown' }))!
240
- let result = `z.array(${inner})${lengthConstraints(node)}`
266
+ const base = `z.array(${inner})${lengthConstraints(node)}`
241
267
 
242
- if (node.unique) {
243
- result += `.refine(items => new Set(items).size === items.length, { message: "Array entries must be unique" })`
244
- }
245
-
246
- return result
268
+ return node.unique ? `${base}.refine(items => new Set(items).size === items.length, { message: "Array entries must be unique" })` : base
247
269
  },
248
270
  tuple(node) {
249
271
  const items = (node.items ?? []).map((item) => this.transform(item)).filter(Boolean)
@@ -252,7 +274,13 @@ export const printerZod = ast.definePrinter<PrinterZodFactory>((options) => {
252
274
  },
253
275
  union(node) {
254
276
  const nodeMembers = node.members ?? []
255
- const members = nodeMembers.map((m) => this.transform(m)).filter(Boolean)
277
+ const members = nodeMembers
278
+ .map((memberNode) => {
279
+ const member = this.transform(memberNode)
280
+
281
+ return member && node.strategy === 'one' ? strictOneOfMember(member, memberNode) : member
282
+ })
283
+ .filter(Boolean)
256
284
  if (members.length === 0) return ''
257
285
  if (members.length === 1) return members[0]!
258
286
  if (node.discriminatorPropertyName && !nodeMembers.some((m) => m.type === 'intersection')) {
@@ -270,61 +298,37 @@ export const printerZod = ast.definePrinter<PrinterZodFactory>((options) => {
270
298
  const [first, ...rest] = members
271
299
  if (!first) return ''
272
300
 
273
- let base = this.transform(first)
274
- if (!base) return ''
301
+ const firstBase = this.transform(first)
302
+ if (!firstBase) return ''
275
303
 
276
- for (const member of rest) {
277
- if (member.primitive === 'string') {
278
- const s = ast.narrowSchema(member, 'string')
279
- const c = lengthConstraints(s ?? {})
280
- if (c) {
281
- base += c
282
- continue
283
- }
284
- } else if (member.primitive === 'number' || member.primitive === 'integer') {
285
- const n = ast.narrowSchema(member, 'number') ?? ast.narrowSchema(member, 'integer')
286
- const c = numberConstraints(n ?? {})
287
- if (c) {
288
- base += c
289
- continue
290
- }
291
- } else if (member.primitive === 'array') {
292
- const a = ast.narrowSchema(member, 'array')
293
- const c = lengthConstraints(a ?? {})
294
- if (c) {
295
- base += c
296
- continue
297
- }
298
- }
304
+ return rest.reduce((acc, member) => {
305
+ const constraint = getMemberConstraint(member)
306
+ if (constraint) return acc + constraint
299
307
  const transformed = this.transform(member)
300
- if (transformed) base = `${base}.and(${transformed})`
301
- }
302
-
303
- return base
308
+ return transformed ? `${acc}.and(${transformed})` : acc
309
+ }, firstBase)
304
310
  },
305
311
  ...options.nodes,
306
312
  },
307
313
  print(node) {
308
314
  const { keysToOmit } = this.options
309
315
 
310
- let base = this.transform(node)
311
- if (!base) return null
316
+ const transformed = this.transform(node)
317
+ if (!transformed) return null
312
318
 
313
319
  const meta = ast.syncSchemaRef(node)
314
320
 
315
- if (keysToOmit?.length && meta.primitive === 'object' && !(meta.type === 'union' && meta.discriminatorPropertyName)) {
321
+ const base = (() => {
322
+ if (!keysToOmit?.length || meta.primitive !== 'object' || (meta.type === 'union' && meta.discriminatorPropertyName)) return transformed
316
323
  // Mirror printerTs `nonNullable: true`: when omitting keys, the resulting
317
324
  // schema is a new non-nullable object type — skip optional/nullable/nullish.
318
325
  // Discriminated unions (z.discriminatedUnion) do not support .omit(), so skip them.
319
326
 
320
327
  // If this is a lazy reference, apply omit inside the lazy function
321
- const lazyMatch = base.match(/^z\.lazy\(\(\)\s*=>\s*(.+)\)$/)
322
- if (lazyMatch) {
323
- base = `z.lazy(() => ${lazyMatch[1]}.omit({ ${keysToOmit.map((k: string) => `"${k}": true`).join(', ')} }))`
324
- } else {
325
- base = `${base}.omit({ ${keysToOmit.map((k: string) => `"${k}": true`).join(', ')} })`
326
- }
327
- }
328
+ const lazyMatch = transformed.match(/^z\.lazy\(\(\)\s*=>\s*(.+)\)$/)
329
+ if (lazyMatch) return `z.lazy(() => ${lazyMatch[1]}.omit({ ${keysToOmit.map((k: string) => `"${k}": true`).join(', ')} }))`
330
+ return `${transformed}.omit({ ${keysToOmit.map((k: string) => `"${k}": true`).join(', ')} })`
331
+ })()
328
332
 
329
333
  return applyModifiers({
330
334
  value: base,