Skip to content

Commit 445709b

Browse files
chmaltspclaude
andcommitted
feat: add Zod 4 compatibility for query param type detection
The internal Zod schema structure changed between Zod 3 and Zod 4: - `_def.typeName` -> `_def.type` - `ZodFirstPartyTypeKind` enum values -> lowercase string literals - `ZodEffects._def.schema` -> pipe `_def.in` This update adds helper functions to detect Zod types in a way that works with both Zod 3 and Zod 4: - `getZodTypeIdentifier()` - returns type from either `_def.type` or `_def.typeName` - `isZodType()` - checks against both Zod 3 enum and Zod 4 string Type mappings: - ZodOptional -> "optional" - ZodDefault -> "default" - ZodEffects -> "pipe" - ZodObject -> "object" - ZodArray -> "array" - ZodBoolean -> "boolean" Files updated: - src/middleware/with-input-validation.ts - query param type detection - src/cli/commands/codegen/openapi.ts - commonParams type check This ensures boolean query params are correctly coerced from strings (e.g., "true" -> true) in both Zod versions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8f04e6b commit 445709b

File tree

4 files changed

+92
-30
lines changed

4 files changed

+92
-30
lines changed

package-lock.json

Lines changed: 13 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@
164164
"ts-morph": "^21.0.1",
165165
"watcher": "^2.3.0",
166166
"yargs": "^17.7.2",
167-
"zod": "^3.22.4"
167+
"zod": "^3.22.4 || ^4.0.0"
168168
},
169169
"peerDependencies": {
170170
"@ava/get-port": ">=2.0.0",

src/cli/commands/codegen/openapi.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ export class CodeGenOpenAPI extends BaseCommand {
108108
const areCommonParamsRequiredInQuery =
109109
HTTP_METHODS_WITHOUT_BODY.includes(method.toLowerCase())
110110
if (!areCommonParamsRequiredInQuery) {
111-
if (commonParams?._def.typeName === "ZodObject") {
111+
// Support both Zod 3 (_def.typeName) and Zod 4 (_def.type)
112+
const commonParamsType =
113+
commonParams?._def.type ?? commonParams?._def.typeName
114+
if (commonParamsType === "ZodObject" || commonParamsType === "object") {
112115
commonParams = (commonParams as ZodObject<any>).partial()
113116
} else {
114117
commonParams = commonParams?.optional()

src/middleware/with-input-validation.ts

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ import {
1010
InvalidQueryParamsError,
1111
} from "./http-exceptions.js"
1212

13+
/**
14+
* Get the type identifier from a Zod schema definition.
15+
* Supports both Zod 3 (_def.typeName) and Zod 4 (_def.type).
16+
*/
17+
const getZodTypeIdentifier = (def: any): string | undefined => {
18+
// Zod 4 uses _def.type (lowercase string like "string", "boolean", "optional")
19+
// Zod 3 uses _def.typeName (ZodFirstPartyTypeKind enum values)
20+
return def.type ?? def.typeName
21+
}
22+
23+
/**
24+
* Check if a type identifier matches a specific Zod type.
25+
* Supports both Zod 3 (ZodFirstPartyTypeKind) and Zod 4 (lowercase strings).
26+
*/
27+
const isZodType = (
28+
typeIdentifier: string | undefined,
29+
zod3Kind: (typeof ZodFirstPartyTypeKind)[keyof typeof ZodFirstPartyTypeKind],
30+
zod4Type: string
31+
): boolean => {
32+
return typeIdentifier === zod3Kind || typeIdentifier === zod4Type
33+
}
34+
1335
const getZodObjectSchemaFromZodEffectSchema = (
1436
isZodEffect: boolean,
1537
schema: z.ZodTypeAny
@@ -18,50 +40,75 @@ const getZodObjectSchemaFromZodEffectSchema = (
1840
return schema as z.ZodObject<any>
1941
}
2042

21-
let currentSchema = schema
22-
23-
while (currentSchema instanceof z.ZodEffects) {
24-
currentSchema = currentSchema._def.schema
43+
let currentSchema = schema as any
44+
45+
// Handle both Zod 3 (ZodEffects with _def.schema) and Zod 4 (pipe with _def.in)
46+
while (
47+
currentSchema instanceof z.ZodEffects ||
48+
getZodTypeIdentifier(currentSchema._def) === "pipe"
49+
) {
50+
if (currentSchema instanceof z.ZodEffects) {
51+
currentSchema = currentSchema._def.schema
52+
} else if (currentSchema._def.in) {
53+
// Zod 4 pipe structure
54+
currentSchema = currentSchema._def.in
55+
} else {
56+
break
57+
}
2558
}
2659

2760
return currentSchema as z.ZodObject<any>
2861
}
2962

3063
/**
3164
* This function is used to get the correct schema from a ZodEffect | ZodDefault | ZodOptional schema.
32-
* TODO: this function should handle all special cases of ZodSchema and not just ZodEffect | ZodDefault | ZodOptional
65+
* Supports both Zod 3 and Zod 4 internal structures.
3366
*/
3467
const getZodDefFromZodSchemaHelpers = (schema: z.ZodTypeAny) => {
35-
const special_zod_types = [
36-
ZodFirstPartyTypeKind.ZodOptional,
37-
ZodFirstPartyTypeKind.ZodDefault,
38-
ZodFirstPartyTypeKind.ZodEffects,
39-
]
40-
41-
while (special_zod_types.includes(schema._def.typeName)) {
68+
let currentSchema = schema as any
69+
let typeId = getZodTypeIdentifier(currentSchema._def)
70+
71+
// Keep unwrapping optional, default, effects/pipe until we get to the base type
72+
while (
73+
isZodType(typeId, ZodFirstPartyTypeKind.ZodOptional, "optional") ||
74+
isZodType(typeId, ZodFirstPartyTypeKind.ZodDefault, "default") ||
75+
isZodType(typeId, ZodFirstPartyTypeKind.ZodEffects, "pipe")
76+
) {
4277
if (
43-
schema._def.typeName === ZodFirstPartyTypeKind.ZodOptional ||
44-
schema._def.typeName === ZodFirstPartyTypeKind.ZodDefault
78+
isZodType(typeId, ZodFirstPartyTypeKind.ZodOptional, "optional") ||
79+
isZodType(typeId, ZodFirstPartyTypeKind.ZodDefault, "default")
4580
) {
46-
schema = schema._def.innerType
47-
continue
81+
currentSchema = currentSchema._def.innerType
82+
} else if (isZodType(typeId, ZodFirstPartyTypeKind.ZodEffects, "pipe")) {
83+
// Zod 3 uses _def.schema, Zod 4 uses _def.in
84+
currentSchema = currentSchema._def.schema ?? currentSchema._def.in
4885
}
4986

50-
if (schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects) {
51-
schema = schema._def.schema
52-
continue
87+
if (!currentSchema?._def) {
88+
break
5389
}
90+
typeId = getZodTypeIdentifier(currentSchema._def)
5491
}
55-
return schema._def
92+
93+
return currentSchema._def
5694
}
5795

5896
const tryGetZodSchemaAsObject = (
5997
schema: z.ZodTypeAny
6098
): z.ZodObject<any> | undefined => {
61-
const isZodEffect = schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects
99+
const typeId = getZodTypeIdentifier(schema._def)
100+
const isZodEffect = isZodType(
101+
typeId,
102+
ZodFirstPartyTypeKind.ZodEffects,
103+
"pipe"
104+
)
62105
const safe_schema = getZodObjectSchemaFromZodEffectSchema(isZodEffect, schema)
63-
const isZodObject =
64-
safe_schema._def.typeName === ZodFirstPartyTypeKind.ZodObject
106+
const safeTypeId = getZodTypeIdentifier(safe_schema._def)
107+
const isZodObject = isZodType(
108+
safeTypeId,
109+
ZodFirstPartyTypeKind.ZodObject,
110+
"object"
111+
)
65112

66113
if (!isZodObject) {
67114
return undefined
@@ -72,12 +119,14 @@ const tryGetZodSchemaAsObject = (
72119

73120
const isZodSchemaArray = (schema: z.ZodTypeAny) => {
74121
const def = getZodDefFromZodSchemaHelpers(schema)
75-
return def.typeName === ZodFirstPartyTypeKind.ZodArray
122+
const typeId = getZodTypeIdentifier(def)
123+
return isZodType(typeId, ZodFirstPartyTypeKind.ZodArray, "array")
76124
}
77125

78126
const isZodSchemaBoolean = (schema: z.ZodTypeAny) => {
79127
const def = getZodDefFromZodSchemaHelpers(schema)
80-
return def.typeName === ZodFirstPartyTypeKind.ZodBoolean
128+
const typeId = getZodTypeIdentifier(def)
129+
return isZodType(typeId, ZodFirstPartyTypeKind.ZodBoolean, "boolean")
81130
}
82131

83132
const parseQueryParams = (

0 commit comments

Comments
 (0)