泛型函数中的逆变问题
Contravariance problem in generic function
考虑以下类型:
type TComp <T> = (cb: (arg: T) => void, value: T) => void;
以及这种类型的两个实现:
const f1: TComp<number> = (cb: (a: number) => void, value: number) => {
cb(value + 122);
}
const f2: TComp<string> = (cb: (a: string) => void, value: string) => {
cb(value + "ok");
}
现在我介绍类型的字符串名称:
type TStrNum = 'str'|'num';
type TNameTypeMap = {
'str': string;
'num': number;
}
现在我要构造函数,TComp <T>
这样:
const sf = <T extends TStrNum>(cb: (a: TNameTypeMap[T]) => void, value: TNameTypeMap[T], name: TStrNum) => {
if(name==='num') {
return f1(cb, value); //1 ts complains on `cb`
}
if(name==='str') {
return f2(cb, value); //2 ts complains on `cb`
}
}
在 //1
TypeScript 抱怨:
Argument of type '(a: TNameTypeMap[T]) => void' is not assignable to parameter of type '(arg: number) => void'.
Types of parameters 'a' and 'arg' are incompatible.
Type 'number' is not assignable to type 'TNameTypeMap[T]'.
Type 'number' is not assignable to type 'never'.
在//2
中出现同样的错误,只是抱怨字符串
看起来这个问题与cb
的逆变有关,但我仍然无法弄清楚到底出了什么问题。
是否可以修复它或以其他方式实现此功能?
在我们担心实现之前,您首先应该从函数的调用方考虑一个问题。通过将 name
参数键入 TStrNum
,您不需要 name
参数与 value
和 cb
参数关联,因此您可以进行以下操作在没有编译器抱怨的情况下调用:
sfOrig<"num">((a: number) => a.toFixed(), 123, "str"); // no compiler error,
// RUNTIME TypeError: a.toFixed is not a function
这是个问题。解决这个问题的简单方法是使 name
类型为 T
:
const sf = <T extends TStrNum>(
cb: (a: TNameTypeMap[T]) => void,
value: TNameTypeMap[T],
name: T
) => {
if (name === 'num') {
return f1(cb, value); // compiler error
} else {
return f2(cb, value); // compiler error
}
}
这现在给不匹配的输入一个编译器错误:
sf((a: number) => a.toFixed(), 123, "str"); // compiler error!
// ~~~~~~~~~~~~~~~~~~~~~~~~~~
// Argument of type '(a: number) => string' is not assignable
// to parameter of type '(a: string) => void'
sf((a: number) => a.toFixed(), 123, "num"); // okay
既然我们的调用签名可以正常工作,我们就可以担心实现了。 TypeScript 类型分析工作方式的一个局限性在于它并没有真正跟踪 union type or of generic types which are constrained 的多个表达式与联合类型之间的相关性。
首先,无法告诉编译器类型参数 T
必须由 或者 "str"
或者 "num"
而不是完整的并集 "str" | "num"
。因此,编译器无法知道检查 name
对类型参数 T
以及 cb
和 value
有任何影响。在 microsoft/TypeScript#27808 asking for some way to constrain a type parameter like T
to "one of" the members of a union like TStrNum
, but for now there's no way to do it. You'd need to write a bunch of type assertions 有一个功能请求来说服编译器:
const sf = <T extends TStrNum>(
cb: (a: TNameTypeMap[T]) => void,
value: TNameTypeMap[T],
name: T
) => {
if (name === 'num') {
return f1(cb as (a: TNameTypeMap["num"]) => void, value as TNameTypeMap["num"]);
} else {
return f2(cb as (a: TNameTypeMap["str"]) => void, value as TNameTypeMap["str"]);
}
}
这清除了编译器错误,但失去了类型安全性...也就是说,编译器无法区分那个和这个之间的区别:
const sfOops = <T extends TStrNum>(
cb: (a: TNameTypeMap[T]) => void,
value: TNameTypeMap[T],
name: T
) => {
if (name === 'str') { // <-- oops
return f1(cb as (a: TNameTypeMap["num"]) => void, value as TNameTypeMap["num"]);
} else {
return f2(cb as (a: TNameTypeMap["str"]) => void, value as TNameTypeMap["str"]);
}
}
如果你想在不从调用者的角度做太多改变的情况下保持类型安全,那么你需要使用一个输入数据结构,其中可以使用检查一个元素(在本例中为 name
参数) 区分整个数据结构的类型。在 TypeScript 中唯一这样的数据结构是 discriminated union.
因此,与其将函数输入设为三个受相关并集约束的独立参数,不如将其设为单个 rest parameter of a union of tuple types.
这是一种计算该类型的方法:
type Args = { [P in TStrNum]:
[cb: (a: TNameTypeMap[P]) => void, value: TNameTypeMap[P], name: P]
}[TStrNum]
// type Args =
// [cb: (a: string) => void, value: string, name: "str"] |
// [cb: (a: number) => void, value: number, name: "num"]
const sf = (...args: Args) => { /* impl */ }
您可以看到,通过将 args
设置为 Args
,我们实际上将调用限制为两种形式之一:sf((a: string)=>{}, "", "str")
或 sf((a: number)=>{}, 0, "num")
。你不能混搭。而Args
是第三个元素为判别式的判别并集
从实现的角度来看,还有一个更烦人的问题:
const sf = (...[cb, value, name]: Args) => {
if (name === 'num') {
return f1(cb, value); // error!
} else {
return f2(cb, value); // error!
}
}
如果您尝试立即 destructure the argument tuple into cb
, value
, and name
variables, the compiler completely loses all correlation between them. This is one of the general limitations of correlated unions as reported in microsoft/TypeScript#30581; it might be fixed relatively soon by microsoft/TypeScript#46266 但截至目前 (TS4.4),它还不是语言的一部分。
这意味着我们需要将元组保持为元组,以便缩小联合。这给出了这个实现:
const sf = (...args: Args) => {
if (args[2] === 'num') {
return f1(args[0], args[1]);
} else {
return f2(args[0], args[1]);
}
}
现在调用端和实现端都没有错误。
考虑以下类型:
type TComp <T> = (cb: (arg: T) => void, value: T) => void;
以及这种类型的两个实现:
const f1: TComp<number> = (cb: (a: number) => void, value: number) => {
cb(value + 122);
}
const f2: TComp<string> = (cb: (a: string) => void, value: string) => {
cb(value + "ok");
}
现在我介绍类型的字符串名称:
type TStrNum = 'str'|'num';
type TNameTypeMap = {
'str': string;
'num': number;
}
现在我要构造函数,TComp <T>
这样:
const sf = <T extends TStrNum>(cb: (a: TNameTypeMap[T]) => void, value: TNameTypeMap[T], name: TStrNum) => {
if(name==='num') {
return f1(cb, value); //1 ts complains on `cb`
}
if(name==='str') {
return f2(cb, value); //2 ts complains on `cb`
}
}
在 //1
TypeScript 抱怨:
Argument of type '(a: TNameTypeMap[T]) => void' is not assignable to parameter of type '(arg: number) => void'.
Types of parameters 'a' and 'arg' are incompatible.
Type 'number' is not assignable to type 'TNameTypeMap[T]'.
Type 'number' is not assignable to type 'never'.
在//2
中出现同样的错误,只是抱怨字符串
看起来这个问题与cb
的逆变有关,但我仍然无法弄清楚到底出了什么问题。
是否可以修复它或以其他方式实现此功能?
在我们担心实现之前,您首先应该从函数的调用方考虑一个问题。通过将 name
参数键入 TStrNum
,您不需要 name
参数与 value
和 cb
参数关联,因此您可以进行以下操作在没有编译器抱怨的情况下调用:
sfOrig<"num">((a: number) => a.toFixed(), 123, "str"); // no compiler error,
// RUNTIME TypeError: a.toFixed is not a function
这是个问题。解决这个问题的简单方法是使 name
类型为 T
:
const sf = <T extends TStrNum>(
cb: (a: TNameTypeMap[T]) => void,
value: TNameTypeMap[T],
name: T
) => {
if (name === 'num') {
return f1(cb, value); // compiler error
} else {
return f2(cb, value); // compiler error
}
}
这现在给不匹配的输入一个编译器错误:
sf((a: number) => a.toFixed(), 123, "str"); // compiler error!
// ~~~~~~~~~~~~~~~~~~~~~~~~~~
// Argument of type '(a: number) => string' is not assignable
// to parameter of type '(a: string) => void'
sf((a: number) => a.toFixed(), 123, "num"); // okay
既然我们的调用签名可以正常工作,我们就可以担心实现了。 TypeScript 类型分析工作方式的一个局限性在于它并没有真正跟踪 union type or of generic types which are constrained 的多个表达式与联合类型之间的相关性。
首先,无法告诉编译器类型参数 T
必须由 或者 "str"
或者 "num"
而不是完整的并集 "str" | "num"
。因此,编译器无法知道检查 name
对类型参数 T
以及 cb
和 value
有任何影响。在 microsoft/TypeScript#27808 asking for some way to constrain a type parameter like T
to "one of" the members of a union like TStrNum
, but for now there's no way to do it. You'd need to write a bunch of type assertions 有一个功能请求来说服编译器:
const sf = <T extends TStrNum>(
cb: (a: TNameTypeMap[T]) => void,
value: TNameTypeMap[T],
name: T
) => {
if (name === 'num') {
return f1(cb as (a: TNameTypeMap["num"]) => void, value as TNameTypeMap["num"]);
} else {
return f2(cb as (a: TNameTypeMap["str"]) => void, value as TNameTypeMap["str"]);
}
}
这清除了编译器错误,但失去了类型安全性...也就是说,编译器无法区分那个和这个之间的区别:
const sfOops = <T extends TStrNum>(
cb: (a: TNameTypeMap[T]) => void,
value: TNameTypeMap[T],
name: T
) => {
if (name === 'str') { // <-- oops
return f1(cb as (a: TNameTypeMap["num"]) => void, value as TNameTypeMap["num"]);
} else {
return f2(cb as (a: TNameTypeMap["str"]) => void, value as TNameTypeMap["str"]);
}
}
如果你想在不从调用者的角度做太多改变的情况下保持类型安全,那么你需要使用一个输入数据结构,其中可以使用检查一个元素(在本例中为 name
参数) 区分整个数据结构的类型。在 TypeScript 中唯一这样的数据结构是 discriminated union.
因此,与其将函数输入设为三个受相关并集约束的独立参数,不如将其设为单个 rest parameter of a union of tuple types.
这是一种计算该类型的方法:
type Args = { [P in TStrNum]:
[cb: (a: TNameTypeMap[P]) => void, value: TNameTypeMap[P], name: P]
}[TStrNum]
// type Args =
// [cb: (a: string) => void, value: string, name: "str"] |
// [cb: (a: number) => void, value: number, name: "num"]
const sf = (...args: Args) => { /* impl */ }
您可以看到,通过将 args
设置为 Args
,我们实际上将调用限制为两种形式之一:sf((a: string)=>{}, "", "str")
或 sf((a: number)=>{}, 0, "num")
。你不能混搭。而Args
是第三个元素为判别式的判别并集
从实现的角度来看,还有一个更烦人的问题:
const sf = (...[cb, value, name]: Args) => {
if (name === 'num') {
return f1(cb, value); // error!
} else {
return f2(cb, value); // error!
}
}
如果您尝试立即 destructure the argument tuple into cb
, value
, and name
variables, the compiler completely loses all correlation between them. This is one of the general limitations of correlated unions as reported in microsoft/TypeScript#30581; it might be fixed relatively soon by microsoft/TypeScript#46266 但截至目前 (TS4.4),它还不是语言的一部分。
这意味着我们需要将元组保持为元组,以便缩小联合。这给出了这个实现:
const sf = (...args: Args) => {
if (args[2] === 'num') {
return f1(args[0], args[1]);
} else {
return f2(args[0], args[1]);
}
}
现在调用端和实现端都没有错误。