泛型函数中的逆变问题

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 参数与 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 type or of generic types which are constrained 的多个表达式与联合类型之间的相关性。

首先,无法告诉编译器类型参数 T 必须由 或者 "str" 或者 "num" 而不是完整的并集 "str" | "num"。因此,编译器无法知道检查 name 对类型参数 T 以及 cbvalue 有任何影响。在 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]);
  }
}

现在调用端和实现端都没有错误。

Playground link to code