strictFunctionTypes 限制泛型类型。

6
问题似乎特定于strictFunctionTypes如何影响泛型类类型。

这里有一个类,它紧密地复制了发生的事情,并且由于要求无法进一步简化,any用于指定不会添加额外限制的部分(一个playground):

class Foo<T> {
    static manyFoo(): Foo<any[] | { [s: string]: any }>;
    static manyFoo(): Foo<any[]> {
        return ['stub'] as any;
    }

    barCallback!: (val: T) => void;

    constructor() {
        // get synchronously from elsewhere
        (callback => {
            this.barCallback = callback;
        })((v: any) => {});
    }

    baz(callback: ((val: T) => void)): void {}
}

barCallback 签名中的泛型类型 T 导致类型错误:

(method) Foo<T>.manyFoo(): Foo<any[]>
This overload signature is not compatible with its implementation signature.(2394)

问题仅在barCallback函数类型中将T用作val类型时出现。
如果barCallbackbaz不使用T作为参数类型,则问题消失:
barCallback!: (val: any) => void | T;

如果没有太多的manyFoo方法重载或签名不够多样化,它就会消失。
如果barCallback在类中具有方法签名,则不会出现,但这将防止以后对其进行分配。
barCallback!(val: T): void;

在这种情况下,严格的val类型并不是必要的,可以被牺牲。由于barCallback不能用类中的方法签名替换,接口合并似乎是一种抑制错误而不进一步放宽类型的方法:
interface Foo<T> {
  barCallback(val: T): void;
}

在类似情况下是否有其他可行的解决方法?

我希望能够解释为什么函数类型中的val:T会以这种方式影响类类型。


为什么barCallback不能被方法签名所替代?https://www.typescriptlang.org/play?#code/MYGwhgzhAEBiD28A8AVAfNA3gKGn6EALmIQJbDQC2YAdgJ4LwAUAlAFxyJK10DaAutAA+WaLwgciAJ1I0A5vw49oAXzQBuXPiIlyVHo1YdG3egIw58V6FICmhAK5SaYgOREHAI1eDI0HppWKthaeJ5gUgDCYCAg4cAA1gD8TABuMRwo7NCp8KQAJpqh0MDwNNIOwITwUqxYxVYA9I0AdG0N2vYopJS28A6ETHUAvBYd1tCEABakEC3hUTFxYInQw9BpLGsWKoETqix7eMHF4QBeTMBL8QkcQ+kgmVujOXn5LNm5BVjBJ6XlhH8a2gNFsAHdOMwWNgwPMItFYjdgSMdkA - Aleksey L.
1
抱歉,在编写此内容时丢失了重要的一部分。是的,方法签名存在问题,因为它最初是 barCallback! 并且在同步设置中进行,只是不在构造函数范围内。 - Estus Flask
1个回答

26
这实质上是一个差异性问题。因此,首先需要了解差异性:
关于差异性
给定一个泛型类型Foo,以及两个相关类型Animal和Dog(Dog扩展自Animal)。对于Foo和Foo,有四种可能的关系:
1. 协变 - 继承箭头对于Foo和Foo的方向与Animal和Dog的方向相同,因此Foo是Foo的子类型,这也意味着Foo可以分配给Foo。
type CoVariant<T> = () => T
declare var coAnimal: CoVariant<Animal>
declare var coDog: CoVariant<Dog>
coDog = coAnimal; // 
coAnimal = coDog; // ✅

Contravariance - 继承箭头对于 Foo<Animal>Foo<Dog> 的方向与 AnimalDog 相反,因此 Foo<Animal> 实际上是 Foo<Dog> 的子类型,这也意味着 Foo<Animal> 可以分配给Foo<Dog>
type ContraVariant<T> = (p: T) => void
declare var contraAnimal: ContraVariant<Animal>
declare var contraDog: ContraVariant<Dog>
contraDog = contraAnimal; // ✅
contraAnimal = contraDog; // 

不变性 - 尽管DogAnimal有关联,但Foo<Animal>Foo<Dog>之间没有任何关系,因此它们彼此之间不能互相赋值。{{}}
type InVariant<T> = (p: T) => T
declare var inAnimal: InVariant<Animal>
declare var inDog: InVariant<Dog>
inDog = inAnimal; // 
inAnimal = inDog; // 

Bivariance - 如果DogAnimal相关,则Foo<Animal>既是Foo<Dog>的子类型,也是Foo<Dog>的子类型,这意味着两种类型可以互相赋值。在更严格的类型系统中,这可能是一种病态情况,其中T实际上可能并没有被使用,但在typescript中,方法参数位置被认为是双变量的。

class BiVariant<T> { m(p: T): void {} }
declare var biAnimal: BiVariant<Animal>
declare var biDog: BiVariant<Dog>
biDog = biAnimal; // ✅
biAnimal = biDog; // ✅

所有示例 - Playground链接

那么问题是,T的使用如何影响方差?在TypeScript中,类型参数的位置确定了方差,以下是一些示例:

  1. 协变 - T用作字段或函数的返回类型
  2. 反变 - T用作strictFunctionTypes下函数签名的参数
  3. 不变 - T同时用作协变和反变位置
  4. 双变 - T用作strictFunctionTypes下方法定义的参数,或者如果关闭了strictFunctionTypes,则用作任一方法或函数的参数类型。

解释为什么在strictFunctionTypes下方法和函数参数有不同行为的原因在这里解释:

严格检查适用于所有函数类型,除了源自方法或构造函数声明的类型。排除方法是为了确保泛型类和接口(例如Array)继续基本上与协变相关。严格检查方法的影响将是一个更大的破坏性改变,因为大量的泛型类型将变成不变(即使如此,我们可能会继续探索这种更严格的模式)。
回到问题
让我们看看 T 的使用如何影响 Foo 的差异性。
  • barCallback!: (val: T) => void; - 用作成员函数的参数 -> 相反的位置

  • baz(callback: ((val: T) => void)): void - 用作另一个函数的回调参数的参数。这有点棘手,剧透警告,这将被证明是协变的。让我们考虑这个简化的例子:

type FunctionWithCallback<T> = (cb: (a: T) => void) => void

// FunctionWithCallback<Dog> can be assigned to FunctionWithCallback<Animal>
let withDogCb: FunctionWithCallback<Dog> = cb=> cb(new Dog());
let aliasDogCbAsAnimalCb: FunctionWithCallback<Animal> = withDogCb; // ✅
aliasDogCbAsAnimalCb(a => a.animal) // the cb here is getting a dog at runtime, which is fine as it will only access animal members


let withAnimalCb: FunctionWithCallback<Animal> = cb => cb(new Animal());
// FunctionWithCallback<Animal> can NOT be assigned to FunctionWithCallback<Dog>
let aliasAnimalCbAsDogCb: FunctionWithCallback<Dog> = withAnimalCb; // 
aliasAnimalCbAsDogCb(d => d.dog) // the cb here is getting an animal at runtime, which is bad, since it is using `Dog` members

游乐场链接

在第一个例子中,我们传递给aliasDogCbAsAnimalCb的回调函数期望接收一个Animal,因此它只使用Animal成员。实现withDogCb将创建一个Dog并将其传递给回调函数,但这没关系。回调函数将按预期工作,仅使用它期望存在的基类属性。

在第二个例子中,我们传递给aliasAnimalCbAsDogCb的回调函数期望接收一个Dog,因此它使用Dog成员。但是实现withAnimalCb将向回调函数传递一个动物实例。这可能导致运行时错误,因为回调函数最终使用不存在的成员。

因此,只有将FunctionWithCallback<Dog>分配给FunctionWithCallback<Animal>才是安全的,我们得出这样使用T会决定协变性的结论。
结论:
因此,在Foo中,T被用于协变和逆变位置,这意味着FooT方面是不变的。这意味着Foo<any[] | { [s: string]: any }>Foo<any[]>实际上是不相关的类型,就类型系统而言。虽然重载对其检查更加宽松,但它们确实期望重载的返回类型和实现相关(重载的返回值或实现的返回值必须是另一个子类型,ex)。
为什么一些更改可以使其工作:
  • 关闭strictFunctionTypes将使TbarCallback站点成为双变量,因此Foo将是协变的
  • barCallback转换为方法,使T的站点成为双变量,因此Foo将是协变的
  • 删除barCallback将删除逆变使用,因此Foo将是协变的
  • 删除baz将删除T的协变使用,使FooT方面逆变。
解决方法:

您可以开启strictFunctionTypes,并为此回调函数创造一个例外,以使其双变量,方法是使用双变量hack(在这里解释了更狭窄的用例,但相同的原理适用):


type BivariantCallback<C extends (... a: any[]) => any> = { bivarianceHack(...val: Parameters<C>): ReturnType<C> }["bivarianceHack"];


class Foo<T> {
    static manyFoo(): Foo<any[] | { [s: string]: any }>;
    static manyFoo(): Foo<any[]> {
        return ['stub'] as any;
    }

    barCallback!: BivariantCallback<(val: T) => void>;

    constructor() {
        // get synchronously from elsewhere
        (callback => {
            this.barCallback = callback;
        })((v: any) => {});
    }

    baz(callback: ((val: T) => void)): void {}
}

游乐场链接


2
感谢您的详细解释。这是您又一篇很棒的TS文章。 - Estus Flask
1
我给你点赞。我在这里收集了一些与方差相关的问题/答案:https://dev59.com/OVEG5IYBdhLWcg3wac6f - captain-yossarian from Ukraine

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接