PAYATHON 2026

如何让 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,保持类型与校验行为一致。

备注:内容仅供参考。