如何让 Zod 推断类型保留配置中的条件字段和枚举值?
可以用一个泛型捕获整个配置对象,再通过映射类型描述输出字段,并显式声明 schema 的输出类型。目标类型中的 fields? 还需要在运行时通过 .optional() 实现;原代码里的 config.fields? 只表示配置项可以省略。
为什么原来的推断没有达到预期?
原代码分别声明了 F 和 I,但函数的参数类型始终包含 fields、include 这两个可选属性。调用时省略 include,不等于参数类型里没有这个键。
条件展开的每个分支又都是“包含字段的对象或空对象”。直接从泛型函数体推断返回类型,不能可靠地表达“传入哪些配置键,就生成哪些输出键”的对应关系,需要把这种关系写进返回类型。
题目没有提供 TypeScript、Zod 版本和编译选项,因此不能仅凭示例确定 unknown 的具体产生路径,也不能直接归因于某个版本缺陷。
此外,z.object({ fields: z.enum(...) }) 中的 fields 默认必填。要允许字段缺失或传入 undefined,需要对字段 schema 调用 .optional()。参见 Zod 可选字段文档。
用配置对象泛型声明输出类型
下面的代码保留了原题的非空字符串元组约束和 z.enum(values) 写法。代码使用 const 类型参数,需要 TypeScript 5.0 或更高版本,并应开启 strict。
import { z } from "zod";
type EnumValues = readonly [string, ...string[]];
type QueryConfig = {
fields?: EnumValues;
include?: EnumValues;
};
type QueryOutput<C extends QueryConfig> = {
[K in keyof C & keyof QueryConfig as
Extract<C[K], EnumValues> extends never ? never : K
]?: Extract<C[K], EnumValues>[number] | undefined;
};
const createEnumSchema = <const T extends EnumValues>(values: T) =>
z.enum(values);
const createQuerySchema = <const C extends QueryConfig>(
config: C
): z.ZodType<QueryOutput<C>> => {
const shape = {
...(config.fields
? { fields: createEnumSchema(config.fields).optional() }
: {}),
...(config.include
? { include: createEnumSchema(config.include).optional() }
: {}),
};
return z.object(shape) as unknown as z.ZodType<QueryOutput<C>>;
};
const querySchema = createQuerySchema({
fields: ["id", "status", "title"],
});
type QueryType = z.infer<typeof querySchema>;
// {
// fields?: "id" | "status" | "title" | undefined;
// }
C 捕获整个实参类型。只传入 fields 时,keyof C 就不包含 include。
Extract<C[K], EnumValues> 提取配置值中的非空元组类型;如果没有这样的类型,就通过键重映射排除该键。随后用 [number] 取得元组元素的联合类型。映射类型的键重映射和可选修饰符可参考 TypeScript 文档。
const 类型参数有助于保留直接传入的数组字面量,避免枚举值直接扩宽为 string。这项语法从 TypeScript 5.0 开始提供。
检查字段和值的对应关系
同时提供两个配置项时,输出类型会包含两个可选字段:
const schema = createQuerySchema({
fields: ["id", "title"],
include: ["author", "comments"],
});
type Query = z.infer<typeof schema>;
// {
// fields?: "id" | "title" | undefined;
// include?: "author" | "comments" | undefined;
// }
const valid: Query = {
fields: "id",
include: "author",
};
// @ts-expect-error "status" 不在 fields 的枚举中
const invalid: Query = { fields: "status" };
运行时可以用以下调用检查枚举校验和可选行为。注释表示按照该实现应得到的结果:
querySchema.safeParse({}).success; // true
querySchema.safeParse({ fields: undefined }).success; // true
querySchema.safeParse({ fields: "id" }).success; // true
querySchema.safeParse({ fields: "missing" }).success; // false
这些字段的值是单个枚举字符串。配置中的数组用来列出允许值,查询字段本身不是数组。
类型断言和字面量保留的边界
返回处的 as unknown as ... 是一处手动类型承诺:编译器没有验证动态构建的 schema 与映射类型完全一致。它依赖当前实现为每个已配置字段创建对应枚举,并调用 .optional()。后续增加配置项或修改校验规则时,需要同步调整类型定义和运行时代码。
此方案将返回类型声明为 z.ZodType<QueryOutput<C>>,满足 z.infer 和解析需求,但会隐藏 .shape、.extend() 等对象 schema 专用成员。如果还需要这些成员,应按项目使用的 Zod 版本进一步声明具体的 z.ZodObject 返回类型。
数组提前保存到变量时,应保留元组字面量:
const fields = ["id", "status", "title"] as const;
const schema = createQuerySchema({ fields });
普通 string[] 既不能保证非空,也没有保存具体字面量;函数无法从已经扩宽的类型中恢复原始枚举值。Zod 的 枚举文档 也说明了这一点。如果提前把整个配置标注为宽泛的 QueryConfig,同样会丢失实际键和值的信息。
如果字段应当必填,需要同时移除运行时的 .optional(),以及输出映射类型中的 ? 和 | undefined,保持类型与校验行为一致。
备注:内容仅供参考。
