递归转换Zod Schema时丢失OpenAPI元数据,如何保留?
我使用@asteasolutions/zod-to-openapi从Zod Schema生成OpenAPI文档,编写了递归工具函数makeSchemaDeepNullable将所有字段转为可空类型,但生成的文档丢失了所有OpenAPI元数据(描述、示例等)。
问题详情
应用makeSchemaDeepNullable转换Schema后,原始Schema中通过.openapi()定义的字段描述、示例等元数据无法保留到最终API文档中,仅顶层的描述能正常显示。
代码示例
带有OpenAPI元数据的原始Department Schema(示例)
// 包含OpenAPI元数据的部门Schema示例 const DepartmentSchema = z.object({ id: z.string().openapi({ description: "部门ID", example: "DEP001" }), name: z.string().openapi({ description: "部门名称", example: "技术部" }), parent: z.object({ id: z.string().openapi({ description: "父部门ID" }) }).optional() }).openapi({ description: "部门信息" });
现有makeSchemaDeepNullable工具函数
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; extendZodWithOpenApi(z); export function makeSchemaDeepNullable<T extends z.ZodTypeAny>(schema: T): any { // ZodObject - Use the type-preserving approach if (schema instanceof z.ZodObject) { const entries = Object.entries(schema.shape) as [string, z.ZodTypeAny][]; const nullableShape = entries.reduce((acc, [key, value]) => { // Recursively make nested schemas nullable acc[key] = makeSchemaDeepNullable(value).nullable(); return acc; }, {} as Record<string, z.ZodTypeAny>); return z.object(nullableShape).strict(); } // ZodArray if (schema instanceof z.ZodArray) { const element = (schema as any).element as z.ZodTypeAny; return z.array(makeSchemaDeepNullable(element)).nullable(); } // ZodUnion if (schema instanceof z.ZodUnion) { const options = ((schema as any).options as z.ZodTypeAny[]).map(opt => makeSchemaDeepNullable(opt) ); return z.union(options as any).nullable(); } // ZodOptional if (schema instanceof z.ZodOptional) { const innerType = (schema as any)._def.innerType as z.ZodTypeAny; return makeSchemaDeepNullable(innerType).nullable().optional(); } // ZodNullable if (schema instanceof z.ZodNullable) { const innerType = (schema as any)._def.innerType as z.ZodTypeAny; return makeSchemaDeepNullable(innerType).nullable(); } // ZodRecord if (schema instanceof z.ZodRecord) { const valueType = (schema as any)._def.valueType as z.ZodTypeAny; const keyType = (schema as any)._def.keyType; return z.record(keyType, makeSchemaDeepNullable(valueType)).nullable(); } // ZodDefault if (schema instanceof z.ZodDefault) { const innerType = (schema as any)._def.innerType as z.ZodTypeAny; return makeSchemaDeepNullable(innerType).nullable().optional(); } // Primitives and others - directly call .nullable() to preserve metadata return schema.nullable(); }
响应Schema的使用方式
export const CreateDepartmentResponseSchema = makeSchemaDeepNullable(DepartmentSchema).openapi({ description: 'Created department details' }) as z.ZodObject<any>; // OpenAPI响应定义 responses: { 201: { description: "Department created successfully", content: { "application/json": { schema: CreateDepartmentResponseSchema, }, }, }, }
预期行为
生成的OpenAPI Schema应保留原始DepartmentSchema中所有字段的描述、示例等元数据,同时所有字段转为可空类型。
实际行为
生成的文档中字段确实显示为可空,但所有字段级的描述、示例元数据全部丢失,仅顶层的“Created department details”描述得以保留。
解决方案
问题根源在于:调用z.object()、z.array()等方法创建新Schema时,原始Schema的OpenAPI元数据不会自动继承,需要手动提取并复用这些元数据。
修改后的makeSchemaDeepNullable函数
核心逻辑:对每个Schema类型,先提取原始的OpenAPI元数据(存储在schema._def.openapi中),在创建新Schema后调用.openapi()方法将元数据传递过去。
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; extendZodWithOpenApi(z); // 扩展类型,用于访问Zod Schema的OpenAPI元数据 type ZodWithOpenApi = z.ZodTypeAny & { _def: { openapi?: Record<string, any>; }; }; export function makeSchemaDeepNullable<T extends z.ZodTypeAny>(schema: T): T { // 提取当前Schema的OpenAPI元数据 const openapiMeta = (schema as ZodWithOpenApi)._def.openapi; // ZodObject处理 if (schema instanceof z.ZodObject) { const entries = Object.entries(schema.shape) as [string, z.ZodTypeAny][]; const nullableShape = entries.reduce((acc, [key, value]) => { acc[key] = makeSchemaDeepNullable(value); return acc; }, {} as Record<string, z.ZodTypeAny>); // 创建新Schema后复用原始元数据 const newSchema = z.object(nullableShape).strict(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); } // ZodArray处理 if (schema instanceof z.ZodArray) { const element = (schema as any).element as z.ZodTypeAny; const newSchema = z.array(makeSchemaDeepNullable(element)).nullable(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); } // ZodUnion处理 if (schema instanceof z.ZodUnion) { const options = ((schema as any).options as z.ZodTypeAny[]).map(opt => makeSchemaDeepNullable(opt) ); const newSchema = z.union(options as any).nullable(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); } // ZodOptional处理 if (schema instanceof z.ZodOptional) { const innerType = (schema as any)._def.innerType as z.ZodTypeAny; const newSchema = makeSchemaDeepNullable(innerType).nullable().optional(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); } // ZodNullable处理 if (schema instanceof z.ZodNullable) { const innerType = (schema as any)._def.innerType as z.ZodTypeAny; const newSchema = makeSchemaDeepNullable(innerType).nullable(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); } // ZodRecord处理 if (schema instanceof z.ZodRecord) { const valueType = (schema as any)._def.valueType as z.ZodTypeAny; const keyType = (schema as any)._def.keyType; const newSchema = z.record(keyType, makeSchemaDeepNullable(valueType)).nullable(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); } // ZodDefault处理 if (schema instanceof z.ZodDefault) { const innerType = (schema as any)._def.innerType as z.ZodTypeAny; const newSchema = makeSchemaDeepNullable(innerType).nullable().optional(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); } // 基础类型处理:调用nullable后复用元数据 const newSchema = schema.nullable(); return openapiMeta ? (newSchema.openapi(openapiMeta) as T) : (newSchema as T); }
关键修改点说明
- 新增
ZodWithOpenApi类型,用于访问@asteasolutions/zod-to-openapi为Zod Schema添加的_def.openapi元数据字段。 - 每个类型分支中,创建新Schema后检查是否存在原始元数据,若存在则调用
.openapi(openapiMeta)将元数据附加到新Schema上。 - 修正ZodObject分支逻辑:移除递归后的重复
.nullable()调用,统一在各分支中处理可空转换,避免重复操作。
验证效果
修改后,调用makeSchemaDeepNullable(DepartmentSchema)生成的Schema会保留所有原始字段的描述、示例等元数据,同时所有字段转为可空类型,顶层的.openapi()调用也能正常叠加元数据。
内容的提问来源于stack exchange,提问作者The witcher

