【问题标题】:Contravariance problem in generic function泛型函数中的逆变问题
【发布时间】:2021-12-09 02:05:27
【问题描述】:

考虑以下类型:

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 &lt;T&gt; 这样:

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`
    }
}

//1TypeScript 抱怨:

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 的逆变有关,但我仍然无法弄清楚到底是什么问题。

是否可以修复它或以其他方式实现此功能?

【问题讨论】:

  • this approach 适合您吗?一个小问题是name: TStrNum 而不是name: T,但即使您修复了编译器无法知道T 不完全是TStrNum(请参阅ms/TS#27808)并且编译器也无法维护不同参数之间的相关性,除非我们将它们留在一个元组中充当discriminated union(尽管ms/TS#46266)如果合并可能会有所帮助)。
  • 如果我在开头链接的代码适合您的用例,那么我可以写一个答案并解释正在发生的事情。如果没有,你能详细说明什么不起作用吗?
  • 只需稍加修改,您的方法就适用于我——我只是将类型数组转换为对象,因此我可以不通过索引而是通过名称来引用值。请回答解释!
  • 当然...如果您不介意更改调用者调用函数的方式,那么这样的更改是有意义的,因为您使用的是更明显的可区分联合类型。我不会把它放在我的答案中,因为它已经足够长了。

标签: typescript contravariance


【解决方案1】:

在我们担心实现之前,您首先应该从函数的调用方考虑一个问题。通过将name 参数键入为TStrNum,您不需要将name 参数与valuecb 参数相关联,因此您可以在编译器不报错的情况下进行以下调用:

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 typegeneric types 的多个表达式之间的相关性,这些表达式是 constrained 与联合类型。

首先,没有办法告诉编译器类型参数 T 必须由 either "str" "num" 指定,而不是完整的联合"str" | "num"。因此编译器无法知道检查name 对类型参数T 以及cbvalue 有任何影响。在microsoft/TypeScript#27808 有一个功能请求,要求以某种方式将T 之类的类型参数限制为TStrNum 之类的联合成员之一,但目前还没有办法做到这一点。你需要写一堆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"]);
  }
}

这清除了编译器错误,但失去了类型安全性……也就是说,编译器无法区分 that 和 this:

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 的一个 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)=&gt;{}, "", "str")sf((a: number)=&gt;{}, 0, "num")。你不能混搭。而Args 是一个判别联合,它的第三个元素是判别式。

从实现的角度来看,还有一个更烦人的问题:

const sf = (...[cb, value, name]: Args) => {
  if (name === 'num') {
    return f1(cb, value); // error!
  } else {
    return f2(cb, value); // error!
  }
}

如果您尝试立即将 destructure 参数元组转换为 cbvaluename 变量,编译器将完全失去它们之间的所有相关性。这是microsoft/TypeScript#30581 中报告的相关联合的一般限制之一; 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]);
  }
}

现在一切正常,调用端和实现端都没有错误。

Playground link to code

【讨论】:

    猜你喜欢
    • 2021-01-22
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-21
    • 2013-11-29
    • 1970-01-01
    • 1970-01-01
    • 2011-04-21
    相关资源
    最近更新 更多