为什么重载的函数声明有时会强制进行无用的类型缩小



给定以下重载函数语句的实现:

function foo(options: "a"): "a";
function foo(options: "b"): "b";
function foo(options: "a" | "b"): "a" | "b" {
switch (options) {
case "a":
return `a`;
case "b":
default:
return `b`;
}
}

我可以这样调用函数:

// this works
const url1 = foo("a");
const url2 = foo("b");

但是,如果使用联合类型"a" | "b":的值调用foo函数,则会出现类型错误

// function call (inside of wrapped function)
type Options = "a" | "b";
const wrapper = (options: Options) => {
// function overloading forces the caller to narrow the type
const url = foo(options); // Error: Type '"a"' is not assignable to type '"b"'.
}

我可以通过解决这个问题

function foo(options: "a"): "a";
function foo(options: "b"): "b";
function foo(options: "a" | "b"): "a" | "b" {
switch (options) {
case "a":
return "a";
case "b":
default:
return "b";
}
}
type Options = "a" | "b";
const wrapper = (options: Options) => {
// Solution: Calling function 'foo' in the exact same way
const url = options === "a" ? foo(options) : foo(options);
}

问题:为什么TypeScript强制我缩小options的值,因为它对我如何调用foo没有影响?

我总是把foo称为foo(options)。如果重载的类型签名差异更大,例如,如果某些重载包含可选参数值,TS当然应该提示我缩小类型。但它不应该推断出在这种情况下没有必要吗?

TypeScript我的问题的游乐场

function foo(options: "a"): "a";
function foo(options: "b"): "b";
function foo(options: "a" | "b"): "a" | "b" {

最后一行是函数实现的开始,但它不是外部世界可见的类型的一部分。因此,对于您编写的代码,传递"a" | "b"类型的内容实际上是不允许的。只有"a""b"自己。

错误消息可能包括以下文本,试图指出问题,尽管如果你以前从未见过它,可能很难理解它的含义:

调用本可以成功实现,但重载的实现签名在外部不可见。

修复方法是在定义中再添加一个重载:

function foo(options: "a"): "a";
function foo(options: "b"): "b";
function foo(options: "a" | "b"): "a" | "b";
function foo(options: "a" | "b"): "a" | "b" {

正如所写的,foo()函数有两个调用签名,它们都不接受像"a" | "b"这样的联合类型。当调用重载函数时,编译器依次检查每个调用签名,直到找到一个有效的,或者找不到任何适用的调用签名并产生错误。由于没有一个单独的调用签名可以接受"a" | "b",因此如果使用这样的联合类型调用它,则会出现错误。

您不是第一个期望编译器在解析对重载函数的调用时能够选择多个调用签名的人。也许编译器可以自动合成调用签名,接受其他调用签名的参数列表的并集。在microsoft/TypeScript#114107上有一个相对长期的开放功能请求要求这样做。

然而,在可预见的未来,情况就是这样


现在,如果你想让编译器接受一个并集,你需要为它添加一个调用签名。由于你目前有2个签名,通过在末尾添加缺失的并集签名将其转换为3是一种合理的方法:

// call signatures
function foo(options: "a"): "a";
function foo(options: "b"): "b";
function foo(options: "a" | "b"): "a" | "b";
// implementation
function foo(options: "a" | "b"): "a" | "b" {
switch (options) {
case "a":
return `a`;
case "b":
default:
return `b`;
}
}

如果你有独立的调用签名,并且你想表示它们中的每一个可能的集合(除了空集),你必须把它变成2-1。这很快变得非常大:

// call signatures
function foo(options: "a"): "a";
function foo(options: "b"): "b";
function foo(options: "c"): "C";
function foo(options: "d"): "Δ";
function foo(options: "a" | "b"): "a" | "b";
function foo(options: "a" | "c"): "a" | "C";
function foo(options: "a" | "d"): "a" | "Δ";
function foo(options: "b" | "c"): "b" | "C";
function foo(options: "b" | "d"): "b" | "Δ";
function foo(options: "c" | "d"): "C" | "Δ";
function foo(options: "a" | "b" | "c"): "a" | "b" | "C";
function foo(options: "a" | "b" | "d"): "a" | "b" | "Δ";
function foo(options: "a" | "c" | "d"): "a" | "C" | "Δ";
function foo(options: "b" | "c" | "d"): "b" | "C" | "Δ";
function foo(options: "a" | "b" | "c" | "d"): "a" | "b" | "C" | "Δ";
// impl
function foo(options: "a" | "b" | "c" | "d"): "a" | "b" | "C" | "Δ" {
switch (options) {
case "a": return `a`;
case "b": return `b`;
case "c": return `C`;
case "d": return `Δ`;
}
}
const ret = foo(Math.random() < 0.5 ? "c" : "d");
ret; // const ret: "C" | "Δ"

不太好。


相反,您可以决定编写一个通用调用签名来表示您正在做的事情:

function foo<T extends "a" | "b">(options: T): T;
function foo(options: "a" | "b"): "a" | "b" {
switch (options) {
case "a":
return `a`;
case "b":
default:
return `b`;
}
}

由于T可以由"a""b""a" | "b"指定,因此输出会自动按照您的意愿进行操作。这也很容易扩展到更大的情况,尽管您需要以其他方式写出转换。如果您的输入都是类似键的类型(如"a""b"),则可以是索引访问:

interface IOMap {
a: "a";
b: "b";
c: "C";
d: "Δ";
}
function foo<T extends keyof IOMap>(options: T): IOMap[T];
function foo(options: keyof IOMap): IOMap[keyof IOMap] {
switch (options) {
case "a": return `a`;
case "b": return `b`;
case "c": return `C`;
case "d": return `Δ`
}
}
const ret = foo(Math.random() < 0.5 ? "c" : "d");
ret; // const ret: "C" | "Δ"

但即使在你不能做到这一点的情况下,你也可以使用分布式条件类型将任意的输入输出关系转换为在联合面前表现适当的东西:

function foo<T extends "a" | "b" | "c" | "d">(options: T):
T extends "a" ? "a" :
T extends "b" ? "b" :
T extends "c" ? "C" :
T extends "d" ? "Δ" :
never
;
function foo(options: "a" | "b" | "c" | "d") {
switch (options) {
case "a": return `a`;
case "b": return `b`;
case "c": return `C`;
case "d": return `Δ`
}
}
const ret = foo(Math.random() < 0.5 ? "c" : "d");
ret; // const ret: "C" | "Δ"

请注意这些是如何线性扩展的,以便最初独立的调用签名产生单个通用调用签名,该签名需要的空间是某个常数倍数。因此,根据您的需要,这将是我的建议,而不是添加过载。


顺便说一句:编译器实际上无法检查switch/case实现是否满足指定的调用签名。它无法执行这种高阶分析。因此,无论发生什么情况,您都需要小心执行。

游乐场链接到代码

最新更新