在TypeScript的类型系统中,函数类型之间的赋值并非简单按参数个数一一对应,剩余参数的处理尤其容易让人困惑。很多人在配置宽松模式后写出能编译的代码,却说不清底层依据,等到开启strictFunctionTypes便突然报错。要真正掌握这部分规则,需要先理解参数位置的变型基础,再看剩余参数如何被特殊处理。

一、函数参数变型与剩余参数的基本关系
TypeScript中,普通函数参数在严格模式下是逆变的:即目标函数参数类型应是源函数参数类型的父类型,才能安全赋值。例如(string) => void可以赋给(any) => void,因为any是string的父类型,调用目标时传入any,源函数只当string用,不会出错。但剩余参数在语法上把多个参数收进一个数组,这个数组类型在比较时并不是逐项逆变,而是作为单一参数类型参与整体兼容判断。
这意味着,当我们写(a: string, ...rest: number[]) => void,其剩余部分对应的类型就是number[]。如果尝试将其赋给(b: string, ...rest: (number | string)[]) => void,从数组元素看number[]似乎是(number | string)[]的子类型,按协变直觉应能赋值,但TypeScript在strictFunctionTypes下会拒绝,因为剩余参数所在的参数位置仍受函数参数逆变约束,而数组整体并未被特殊豁免为协变。
// 严格模式下以下赋值会报错
type Source = (a: string, ...rest: number[]) => void;
type Target = (a: string, ...rest: (number | string)[]) => void;
const src: Source = (a, ...rest) => {
console.log(a, rest);
};
// 错误:类型'(a: string, ...rest: number[]) => void'不能赋给类型'Target'
const tgt: Target = src;
二、宽松模式与strictFunctionTypes的差异
在关闭strictFunctionTypes时,TypeScript为了兼容旧代码,对函数参数采用双向协变检查。此时剩余参数所在的数组类型会按协变处理,上面的Source可以赋给Target而不报错。这种宽松并非语言设计本意,而是历史包袱,它隐藏了类型安全隐患:目标函数可能向剩余数组塞入字符串,而源函数内部按数字处理,运行期才会出问题。
开启strictFunctionTypes后,普通参数位置恢复逆变,但方法声明(如对象字面量中的方法简写)仍有例外,仍使用双向协变。剩余参数总是跟随其所属参数位置的规则,因此若源和目标都是方法写法,即便严格模式也可能不报错。理解这一点能解释为什么同样的签名,放在接口方法里和放在变量里表现不同。
// 方法签名在strictFunctionTypes下仍双向协变
interface Loosely {
fn(a: string, ...rest: number[]): void;
}
interface Strictly {
fn(a: string, ...rest: (number | string)[]): void;
}
// 不报错,因为是方法位置
const obj: Strictly = {
fn: (a, ...rest) => {}
} as Loosely as any;
三、剩余参数协变判定的底层算法
类型比较器在处理函数类型时,会先对齐固定参数,再将剩余参数打包成数组类型放到参数列表末尾。随后对每一个参数位置调用relation检查:严格模式下使用contravariant标记,要求源参数类型是目标参数类型的子类型反向成立。对于剩余参数的数组,比较器不会拆开元素去逐项比对,而是整体比较数组类型,所以number[]与(number|string)[]在逆变要求下互不兼容。
若想让剩余参数真正协变,只能显式放宽,例如把剩余参数改为普通数组参数并标注为只读,或利用类型断言。但更推荐的做法是重新设计函数签名,让调用方传入明确元组或使用泛型约束,避免依赖剩余参数的隐式变型。下表总结了不同配置下的表现。
| 配置 | 剩余参数比较方式 | Source赋Target |
|---|---|---|
| strictFunctionTypes关闭 | 双向协变 | 允许 |
| strictFunctionTypes开启,变量函数 | 逆变 | 拒绝 |
| strictFunctionTypes开启,接口方法 | 双向协变 | 允许 |
四、实践中的避坑与写法建议
写库代码时,如果希望函数能被灵活传入不同剩余参数类型的回调,应使用泛型把剩余参数类型抽象出来,而不是写死数组元素。这样调用方指定类型参数后,类型检查器能给出准确错误,而不是在宽松模式下静默通过。例如定义type Callback<T extends unknown[]> = (...args: T) => void,由使用处决定T。
另一种常见误区是认为剩余参数和数组参数完全等价,于是把(...x: number[])写成(x: number[]),后者在赋值时确实按单一数组参数处理,变型规则相同,但调用语法不同,前者允许分散传参。若API只需数组,直接用数组参数更清晰,也避免开发者对剩余语法产生协变错觉。下面示例展示泛型约束的安全写法。
type Callback<T extends unknown[]> = (...args: T) => void;
function run<T extends unknown[]>(cb: Callback<T>, ...data: T): void {
cb(...data);
}
// 明确数字数组,类型安全
run<[number, ...number[]]>((...nums) => {
console.log(nums.reduce((a, b) => a + b, 0));
}, 1, 2, 3);
五、小结
剩余参数在TypeScript类型兼容性中并不是一个独立协变的避风港。严格模式下它随参数位置逆变,宽松或方法位置才表现出协变假象。弄清比较器把剩余参数当作整体数组、不拆元素比对这一点,就能解释多数报错。日常编码建议用泛型或显式数组参数替代模糊的剩余写法,让类型系统替你守住边界。
TypeScript类型兼容性协变修改时间:2026-08-10 23:48:35