使用剩余参数和元组来缩小函数参数的范围你是否曾困惑于如何实现一个函数,使其第二个参数的类型能根据第一个参数的类型自动调整
TypeScript 提供了多种方式来描述一个可以用多种不同方式调用的函数类型。然而,两种最常见的策略——函数重载和泛型函数——在如何让函数内部实现理解其参数类型方面并不太有效。
本文将介绍三种描述参数类型的方法,这些参数类型会根据前一个参数的变化而变化。前两种方法允许使用标准 JavaScript 语法,但在描述函数内部的类型时不太精确。第三种方法需要以一种新颖的方式使用 ...
数组展开和 [...]
元组类型,从而在内部获得正确的类型。
依赖参数类型?那是什么?
假设你想编写一个函数,该函数接受两个参数:
fruit
:"apple"
或"banana"
info
: 如果fruit
是"apple"
,则为AppleInfo
类型;如果fruit
是"banana"
,则为BananaInfo
类型。
在这个函数中,第二个参数的类型取决于第一个参数的类型。
一种初步方法可能是将函数的两个参数(fruit
和 info
)声明为联合类型。简而言之:
declare function logFruit(
fruit: "apple" | "banana",
info: AppleInfo | BananaInfo
): void;
这样可以允许用正确匹配的类型调用函数,比如 "apple"
和一个匹配 AppleInfo
的对象。这可能看起来像这样:
interface AppleInfo {
color: "green" | "red";
}
interface BananaInfo {
curvature: number;
}
function logFruit(fruit: "apple" | "banana", info: AppleInfo | BananaInfo) {
switch (fruit) {
case "apple":
console.log(`My apple's color is ${(info as AppleInfo).color}.`);
break;
case "banana":
console.log(
`My banana's curvature is ${(info as BananaInfo).curvature}.`
);
break;
}
}
logFruit("apple", { color: "green" }); // Ok
logFruit("banana", { color: "green" }); // 应该报错,但没有...
该代码块中有两个问题:
- 即使在
fruit
类型已被缩小的情况下,例如在case "apple"
内部,也需要手动将info
断言为正确的类型。 - 没有任何机制阻止使用不匹配的类型调用
logFruit
,例如"banana"
和匹配AppleInfo
的对象。
logFruit
函数需要一种方法来表示 info
的类型依赖于 fruit
的类型。
使用函数重载
描述一个具有多种调用方式的函数的一种方法是使用函数重载。回顾函数重载,TypeScript 允许代码在函数实现之前描述任意数量的调用签名。然后允许函数以匹配任何这些调用签名的方式调用。
例如,这个 eitherWay
可以用 number
输入返回 number
,或用 string
输入返回 string
:
function eitherWay(input: number): number;
function eitherWay(input: string): string;
function eitherWay(input: number | string) {
return input;
}
eitherWay(123);
// ^?
eitherWay("abc");
// ^?
用重载描述 logFruit
函数(我们称之为 logFruitOverload
)可能看起来像是为 "apple"
和 "banana"
分别声明一个签名:
// @errors: 2769
interface AppleInfo {
color: "green" | "red";
}
interface BananaInfo {
curvature: number;
// ---cut---
function logFruitOverload(fruit: "apple", info: AppleInfo): void;
function logFruitOverload(fruit: "banana", info: BananaInfo): void;
function logFruitOverload(
fruit: "apple" | "banana",
info: AppleInfo | BananaInfo
) {
switch (fruit) {
case "apple":
console.log(`My apple's color is ${(info as AppleInfo).color}.`);
break;
case "banana":
console.log(
`My banana's curvature is ${(info as BananaInfo).curvature}.`
);
break;
}
}
logFruitOverload("apple", { color: "green" }); // Ok
logFruitOverload("banana", { color: "green" }); // 应该报错
这种函数重载方法在联合类型方面有所改进:
- 使用
"apple"
调用logFruitOverload
允许传递一个匹配AppleInfo
的对象作为info
。 - 使用
"banana"
和AppleInfo
对象调用logFruitOverload
是类型错误。
有进步!
但是,即使有两个重载明确指出每个水果字符串都匹配特定的接口,case "apple"
内部的 info
类型仍然是 AppleInfo | BananaInfo
。你可以通过将光标悬停在 info as AppleInfo
上看到这一点。访问特定水果的属性时,仍然需要显式的 as
断言。
尽管两个重载明确指出每个水果字符串都匹配特定的接口,但 TypeScript 仍无法缩小 info
的类型。函数的实现签名(即其内部实现尊重的内容)未能理解这种关系。
使用泛型函数
另一种尝试类型安全的方法是将其转化为泛型函数。info
的类型取决于 fruit
的类型;因此,使用 fruit
的类型参数可以根据 fruit
的类型缩小 info
的类型。
这个实现声明了一个 Fruit
类型参数,该参数必须是 InfoForFruit
接口的一个键("apple" | "banana"
),然后声明 info
必须是该 Fruit
键下的相应 InfoForFruit
值:
// @errors: 2345
interface AppleInfo {
color: "green" | "red";
}
interface BananaInfo {
curvature: number;
// ---cut---
interface InfoForFruit {
apple: AppleInfo;
banana: BananaInfo;
}
function logFruitGeneric<Fruit extends keyof InfoForFruit>(
fruit: Fruit,
info: InfoForFruit[Fruit]
) {
switch (fruit) {
case "apple":
console.log(`My apple's color is ${(info as AppleInfo).color}.`);
break;
case "banana":
console.log(
`My banana's curvature is ${(info as BananaInfo).curvature}.`
);
break;
}
}
logFruitGeneric("apple", { color: "green" }); // Ok
logFruitGeneric("banana", { color: "green" }); // 应该报错
这个泛型版本在用 "banana"
和错误形状的 info 调用 logFruitGeneric
时提供了一个更友好的错误消息。但遗憾的是,TypeScript 再次无法在 case "apple"
内部缩小 info
的类型。
使用剩余参数
最后一次尝试,让我们结合三种语法:
这个 logFruitTuple
版本使用这三种技术来允许传递符合 FruitAndInfo
元组类型的 fruit
和 info
参数:
// @errors: 2345
interface AppleInfo {
color: "green" | "red";
}
interface BananaInfo {
curvature: number;
// ---cut---
type FruitAndInfo = ["apple", AppleInfo] | ["banana", BananaInfo];
function logFruitTuple(...[fruit, info]: FruitAndInfo) {
switch (fruit) {
case "apple":
console.log(`My apple's color is ${info.color}.`);
break;
case "banana":
console.log(`My banana's curvature is ${info.curvature}.`);
break;
}
}
logFruitTuple("apple", { color: "green" }); // Ok
logFruitTuple("banana", { color: "green" }); // 应该报错
该函数根据 fruit
正确缩小了 info
的类型,并正确标记了用 "banana"
和 AppleInfo
形状的对象调用的错误。好极了!
逐步解释该参数的工作原理:
-
...
是一个剩余参数,收集传递给函数的任何参数到一个数组中。 -
[fruit, info]
收集该数组的前两个元素,并将它们分别存储在fruit
和info
变量中。 -
: FruitAndInfo
将参数类型注释为FruitAndInfo
,这是一个允许两种元组类型的联合类型:["apple", AppleInfo]
["banana", BananaInfo]
通过使用 FruitAndInfo
元组类型和数组解构赋值,TypeScript 可以理解 fruit
的类型,这有助于 info
类型的推断。
小结
本文展示了几种方法来描述一个参数类型取决于另一个参数的函数类型:
- 函数重载:优点是调用签名简洁明了,缺点是内部类型缩小不精确。
- 泛型函数:优点是可以根据类型参数推断,缺点是内部类型缩小不精确。
- 剩余参数和元组类型:优点是内部类型缩小准确,缺点是语法较复杂。
每种方法都有其优缺点,选择取决于具体需求和偏好。在实践中,可以根据需要选择合适的方法来实现类型安全的函数。
转载自:https://juejin.cn/post/7386231613867671563