TYPESCRIPT / FOUNDATIONS

函数兼容性与回调类型

从替换函数是否安全出发,理解参数逆变、返回值协变、上下文类型与回调类型复用。

本章目录CONTENTS ↓

把一个函数交给另一个 API 时,类型检查需要回答两个问题:这个函数能否接住 API 可能传来的输入?它返回的结果是否满足调用者的要求?理解这两点,比只记住赋值方向更容易解释回调报错。

本章先检查函数替换的安全性,再把规则用于格式化回调,最后从已有 API 提取类型,减少重复声明。示例开启 strict,包括 strictFunctionTypesnoImplicitAny;函数兼容部分限定普通函数类型,不把规则直接推广到方法或构造签名。

替换函数时,输入与输出都要满足要求

假设一个处理器只处理进度事件,另一个位置却允许调用者传入保存事件或进度事件。把前者放到后者的位置,会让原本合法的调用遇到无法处理的数据。

ts
type Saved = { kind: "saved"; id: string };
type Progress = { kind: "progress"; percent: number };

type AllHandler = (event: Saved | Progress) => void;
type ProgressHandler = (event: Progress) => void;

const onlyProgress: ProgressHandler = event => {
  console.log(event.percent.toFixed(0));
};

// @ts-expect-error 目标允许 Saved,实际函数只能接收 Progress
const handleAll: AllHandler = onlyProgress;

const logEvent: AllHandler = event => {
  console.log(event.kind);
};
const handleProgress: ProgressHandler = logEvent; // 可以

handleProgress({ kind: "progress", percent: 50 });
// @ts-expect-error 调用按 handleProgress 的声明类型检查
handleProgress({ kind: "saved", id: "a" });

AllHandler 承诺可以接收两种事件,onlyProgress 无法履行这个承诺。反过来,handleProgress 只允许调用者传入进度事件,而 logEvent 能处理这个范围内的所有输入,所以替换成立。赋值后,通过 handleProgress 调用仍按它声明的类型检查,不会开放 logEvent 原本支持的全部输入。

unknown 接受所有输入,但不允许任意使用

unknown 容易让人误以为“不知道类型,所以不能接收”。实际区别在于:任何值都可以赋给 unknown,但拿到它的函数在进行具体操作前需要收窄。

ts
const acceptNumber = (value: number): void => {
  console.log(value.toFixed(2));
};
const acceptUnknown = (value: unknown): void => {
  console.log(value);
};

const useNumber: (value: number) => void = acceptUnknown;
const useString: (value: string) => void = acceptUnknown;

// @ts-expect-error 目标允许字符串等输入,acceptNumber 接不住
const useUnknown: (value: unknown) => void = acceptNumber;

如果最后一次赋值被允许,调用者就可以传入字符串,而实际函数会尝试对字符串调用 toFixed。这说明错误来自输入承诺不一致。能接收 unknown 也不代表可以直接调用字符串或数字方法:只有先做 typeof 等检查,才能安全使用对应操作。

返回值必须满足调用者的预期

输入检查通过,还要检查返回值。目标接受 string | number,实际只返回 string,可以满足要求;目标要求 number,实际返回 string,则不能满足。

ts
const source = (value: unknown): string => String(value);

const a: (value: string) => string | number = source;
// @ts-expect-error 输入兼容,但 string 返回值不能满足 number
const b: (value: string) => number = source;

const formatNumber = (value: number): string => value.toFixed(2);
// @ts-expect-error 返回兼容,但目标还允许传入 string
const format: (value: string | number) => string | number = formatNumber;

最后一行中,返回的 string 可以赋给 string | number,但 format("123") 是目标类型允许的调用,实际函数却处理不了它。两侧必须同时通过,一侧兼容不能抵消另一侧的问题。

检查位置 检查方向 名称
参数 目标允许的输入,能否赋给实际函数的参数类型 参数逆变
返回值 实际返回类型,能否赋给目标返回类型 返回值协变

这里的返回值比较使用普通值类型。目标返回类型为 void 时,有“允许有返回值实现,但调用侧忽略返回类型”的特殊规则,详见事件 API 的 void 说明

实现回调:在内部收窄,并保留上下文类型

一批数据里可能既有数字,也有字符串。统一格式化时,API 会逐项调用回调,因此回调必须能接受两种输入,并在每条路径上返回字符串。

ts
function processValues(
  values: (string | number)[],
  format: (value: string | number) => string
): string[] {
  return values.map(format);
}

const result = processValues([12.3, " hello "], value => {
  if (typeof value === "number") {
    return value.toFixed(2);
  } else {
    return value.trim();
  }
});
// result 的类型是 string[],运行时值为 ["12.30", "hello"]

进入分支前,valuestring | number。它的类型来自 format 的参数声明,这叫上下文类型;本例的 processValues 不是泛型函数,不会根据数组里的具体元素改写回调签名。数字分支收窄为 number,另一个分支收窄为 string,两边都返回 string

为了复用格式化逻辑,常会把回调提取到变量中。但独立声明的位置如果没有提供类型上下文,后续调用不会倒推回来补充参数类型。

ts
// @ts-expect-error 独立声明没有参数类型上下文,value 隐式为 any
const untypedFormatter = value => {
  return typeof value === "number" ? value.toFixed(2) : value.trim();
};

可以直接标注箭头函数参数,也可以标注整个变量。下面选择第二种方式,使变量声明同时提供参数上下文和返回值约束。

ts
const annotatedFormatter: (value: string | number) => string = value => {
  return typeof value === "number" ? value.toFixed(2) : value.trim();
};

processValues([12.3, " hello "], annotatedFormatter);

内联回调适合简短、只用一次的逻辑;抽离回调适合复用或命名。重构时需要检查类型上下文是否仍然存在,不能用 any 掩盖丢失的参数信息。

从现有 API 提取类型,减少重复声明

processValues 已经定义了回调契约,再手写一份相同函数类型,修改 API 时可能漏改。对于这里的普通、非重载函数,可以直接提取第二个参数的类型。

ts
type Formatter = Parameters<typeof processValues>[1];

const formatter: Formatter = value =>
  typeof value === "number" ? value.toFixed(2) : value.trim();

这次类型变换分为三步:typeof processValues 取得函数的类型;Parameters 提取参数列表元组;[1] 取出第二项。

ts
type ProcessArgs = Parameters<typeof processValues>;
// [
//   values: (string | number)[],
//   format: (value: string | number) => string
// ]

valuesformat 是元组标签,不改变从零开始的索引规则。Formatter 本身就是函数类型,因此不需要再对这个类型别名使用 typeof

ts
type FormattedValue = ReturnType<Formatter>;           // string
type FormattedBatch = ReturnType<typeof processValues>; // string[]

第一个结果属于单次格式化回调,第二个属于外层批处理函数。读取复杂 API 时,先确定正在提取哪一层函数,再使用参数或返回值工具。

这种写法适合明确跟随已有 API 契约的回调;如果两个模块需要独立演进,就不应仅因为当前形状相同而强行关联类型。

需要复习提取工具的原理,可阅读 infer 与函数类型提取

官方阅读

这一页,先记到这里。