TYPESCRIPT / FOUNDATIONS

infer 与函数类型提取

从结构中提取属性、参数列表与返回类型,并理解位置和使用边界。

本章目录CONTENTS ↓

一个函数已经定义了输入和输出,表单、测试数据或其他调用方却还要手写一遍相同的类型。签名发生变化时,这些副本容易不同步。本章从这个维护问题出发,解释如何从类型结构中提取信息;完整的测试条目设计见关联实战。

为什么需要从结构中提取类型

假设创建用户的函数类型已经存在,另一个模块需要复用它返回的用户结构。

ts
type CreateUser = () => { id: string; name: string };
type User = ReturnType<CreateUser>;
// { id: string; name: string }

type Id = User["id"]; // string

对象属性可以通过 User["id"] 提取。函数的返回类型却不是一个名叫 return 的属性,不能靠 CreateUser["return"] 访问。这里需要描述函数结构,再提取返回位置上的类型;内置的 ReturnType 已经提供了这种工具。

infer 支持在条件类型匹配结构时,声明一个待推导的类型变量,供匹配成功后的分支使用。它让自定义提取规则成为可能,也帮助我们理解内置工具的机制。

什么时候使用,什么时候直接复用

需求 选择
已有独立导出的业务类型 直接引用,不必从函数反推
读取已知对象的属性类型 索引访问 T["属性名"]
取得函数返回类型或参数列表 优先使用 ReturnTypeParameters
按结构提取并组织成新的业务类型 条件类型配合 infer

提取适合让类型跟随已有签名变化。如果业务契约应该独立于具体实现,单独定义并复用业务类型仍然合适。这里的类型计算不会调用函数,也不会修改运行时对象。

infer 写在哪里,与泛型有什么关系

先看一个没有泛型参数的例子,只关注语法位置:

ts
type Payload =
  { payload: string } extends { payload: infer P }
    ? P
    : never;
// string

infer P 写在条件类型 extends 右侧的结构里。左边匹配这个结构时,payload 位置对应 string,所以 P 推导为 string,真分支返回它。P 是局部的类型变量名称,不是 JavaScript 变量。

不需要先声明一个外层泛型才能使用 infer。通常写成泛型工具,是为了对不同输入重复使用这段提取逻辑。infer 声明只能放在条件类型的匹配位置,不能直接写在普通泛型参数的约束中;推导出的类型变量可以在该条件的真分支使用。

从返回位置提取:复用函数的结果

先只处理能无参调用的普通函数,避免一开始把所有参数形状混在一起。

ts
type GetResult<T> = T extends () => infer R ? R : never;

type LoadNames = () => string[];
type Names = GetResult<LoadNames>; // string[]

T 是整个 () => string[]R 是返回位置上的 string[]。目标是复用返回结果,所以真分支写 R。如果写 T,仍然得到整个函数类型。

这份入门工具不适合需要必填实参的函数;实际取得普通函数的返回类型可以直接使用内置 ReturnType。重载、泛型函数等推导边界不在本章范围内。

从参数位置提取:复用事件数据

如果只拿到了一个事件处理器签名,测试数据需要跟随它接收的事件类型。把 infer 放在参数位置,就能提取这一部分。

ts
type EventParam<T> =
  T extends (e: infer Event) => unknown ? Event : never;

type SavedHandler =
  (e: { kind: "saved"; id: string }) => void;
type FailedHandler =
  (e: { kind: "failed"; error: string }) => void;

type SavedEvent = EventParam<SavedHandler>;
// { kind: "saved"; id: string }
type Events = EventParam<SavedHandler | FailedHandler>;
// { kind: "saved"; id: string }
// | { kind: "failed"; error: string }

这里 e 是参数名,Event 是待推导的类型名称;返回位置的 unknown 不筛选具体返回类型。该例先限定为单个必填参数的普通函数。

对于处理器联合,条件左侧是裸类型参数 T,因此逐成员处理,再把各自提取的结果组成联合。分配由条件左侧的形状决定,提取的位置由 infer 决定。 如果项目已经导出了事件类型,直接引用通常更简单。

多个参数为什么需要元组

准备一次函数调用的测试数据时,只知道“参数可能是字符串或数字”还不够,还要知道它们的顺序和数量。两个必填参数可以用元组描述。

ts
type UpdateArgs = [id: string, count: number];
const updateArgs: UpdateArgs = ["item-1", 3];

type F1 = (id: string, count: number) => void;
type F2 = (...args: [string, number]) => void;

[string, number] 要求两个位置:第一项是字符串,第二项是数字。(string | number)[] 则允许任意长度与任意位置的两种类型,不能准确描述这个调用要求。元组中的标签便于阅读,不改变这里各位置的类型约束。

F1F2 表达相同的调用参数要求。...args 代表整个参数列表,元组类型描述列表的各个位置。于是,可以用 infer 推导这个列表。

ts
type ArgsOf<T> =
  T extends (...args: infer P) => unknown ? P : never;

type Search = (query: string, limit: number) => boolean;
type SearchArgs = ArgsOf<Search>;
// [query: string, limit: number]

P 是完整参数列表的类型,不是所有参数值类型的联合。一般业务直接使用 Parameters<Search> 即可;自定义工具适合继续组合规则。

只需要第一个参数时,再做索引访问

表单可能只需要保存函数的业务数据,不需要后面的重试选项。先提取参数元组,再索引第一项,就可以复用数据类型。

ts
type FirstArg<T> =
  T extends (...args: infer P) => unknown ? P[0] : never;

type Save = (
  data: { id: string; title: string }, retry: boolean
) => void;
type SaveData = FirstArg<Save>;
// { id: string; title: string }

这里的 P[0] 与对象属性索引一样,是类型层面的访问。若已有参数元组,直接索引即可;一次性提取也可以写 Parameters<Save>[0]。本节使用至少一个必填参数的普通函数,空参数与可选参数下如何设计“第一项”需要另外约定。

同时提取多个位置的信息

当一个新类型需要同时描述输入和输出时,可以在同一结构中声明两个待推导变量。下面只演示提取规则;如何组织成测试数据并保留联合配对,见 API 测试实战。

ts
type FunctionParts<T> = T extends (...args: infer P) => infer R
  ? { parameters: P; result: R } : never;
type Parts = FunctionParts<(id: string) => number>;
// { parameters: [id: string]; result: number }
type EmptyParameters = ArgsOf<() => number>; // []
type NonFunction = ArgsOf<string>; // never

空参数列表表示零个参数,函数匹配仍然成功。非函数不匹配结构,才走假分支。这里只讨论自定义工具;内置 Parameters 对非函数实参有约束检查。

官方阅读

这一页,先记到这里。