Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

strictFunctionTypes restricts generic type

The problem seems to be specific to how strictFunctionTypes affects generic class type.

Here is a class that closely reproduces what happens and cannot be simplified further because of requirements, any is used to designate parts that don't put additional restrictions (a 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 {}
}

T generic type in barCallback signature makes causes type error:

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

The problem appears only if T is used as val type in barCallback function type.

It disappears if either barCallback or baz don't use T as parameter type:

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

It disappears if there are no manyFoo method overloads or signatures are less diverse.

It don't appear if barCallback has method signature in class but this prevents it from being assigned later:

barCallback!(val: T): void;

In this case strict val type isn't crucial and could be sacrificed. Since barCallback cannot be replaced with method signature in class, interface merging seems to be a way to suppress the error without loosening types further:

interface Foo<T> {
  barCallback(val: T): void;
}

Are there other are possible workarounds in cases similar to this?

I'd appreciate the explanation why exactly val: T in function types affects class type this way.

like image 633
Estus Flask Avatar asked Nov 29 '22 21:11

Estus Flask


1 Answers

This is at it's core an issue of variance. So first a variance primer:

About variance

Given a generic type Foo<T>, and two related types Animal and Dog extends Animal. There are four possible relationships between Foo<Animal> and Foo<Dog>:

  1. Covariance - The arrow of inheritance points in the same direction for Foo<Animal> and Foo<Dog> as it does for Animal and Dog, so Foo<Dog> is a sub type of Foo<Animal>, which also means Foo<Dog> is assignable to Foo<Animal>
type CoVariant<T> = () => T
declare var coAnimal: CoVariant<Animal>
declare var coDog: CoVariant<Dog>
coDog = coAnimal; // 🚫
coAnimal = coDog; // ✅
  1. Contravariance - The arrow of inheritance points in the opposite direction for Foo<Animal> and Foo<Dog> as it does for Animal and Dog, so Foo<Animal> is a actually sub type of Foo<Dog>, which also means Foo<Animal> is assignable to Foo<Dog>
type ContraVariant<T> = (p: T) => void
declare var contraAnimal: ContraVariant<Animal>
declare var contraDog: ContraVariant<Dog>
contraDog = contraAnimal; // ✅
contraAnimal = contraDog; // 🚫
  1. Invariance - Although Dog and Animal are related Foo<Animal> and Foo<Dog> have no relationship whatsoever between them, so neither is assignable to the other.
type InVariant<T> = (p: T) => T
declare var inAnimal: InVariant<Animal>
declare var inDog: InVariant<Dog>
inDog = inAnimal; // 🚫
inAnimal = inDog; // 🚫
  1. Bivariance - If Dog and Animal are related, both Foo<Animal> is a subtype of Foo<Dog> and Foo<Animal> is a subtype of Foo<Dog> meaning either type is assignable to the other. In a stricter type system, this would be a pathological case, where T might not actually be used, but in typescript, methods parameter positions are considered bi-variant.

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

All Examples - Playground Link

So the question is how does the usage of T impact variance? In typescript the position of a type parameter determines variance, some examples :

  1. Co-varaint - T is used in as a field or as the return type of a function
  2. Contra-varaint - T is used as the parameter of a function signature under strictFunctionTypes
  3. Invariant - T is used in both a covariant and contravariant position
  4. Bi-variant - T is used as the parameter of a method definition under strictFunctionTypes, or as the parameter type of either method or function if strictFunctionTypes are off.

The reasoning for the different behavior of method and function parameters in strictFunctionTypes is explained here:

The stricter checking applies to all function types, except those originating in method or constructor declarations. Methods are excluded specifically to ensure generic classes and interfaces (such as Array) continue to mostly relate covariantly. The impact of strictly checking methods would be a much bigger breaking change as a large number of generic types would become invariant (even so, we may continue to explore this stricter mode).

Back to the question

So lets see how, the usages of T impact the variance of Foo.

  • barCallback!: (val: T) => void; - used as a parameter in member that is a function -> contra variant position

  • baz(callback: ((val: T) => void)): void - used as a parameter in the callback parameter of another function. This is a bit tricky, spoiler alert, this will turn out to be covariant. Lets consider this simplified example:

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

Playground Link

In the first example, the callback we pass to aliasDogCbAsAnimalCb expects to receive an Animal, so it only uses Animal members. The implementation withDogCb will create a Dog and pass it to the callback, but this is fine. The callback will work as expected using just the base class properties it expects are there.

In the second example, the callback we pass to aliasAnimalCbAsDogCb expects to receive a Dog, so it uses Dog members. But the implementation withAnimalCb will pass into the callback an instance of an animal. This can leas to runtime errors as the callback ends up using members that are not there.

So given it is only safe to assign FunctionWithCallback<Dog> to FunctionWithCallback<Animal>, we arrive at the conclusion that such a usage of T determines covariance.

Conclusion

So we have T used in both a covariant and a contravariant position in Foo, this means that Foo is invariant in T. This means that Foo<any[] | { [s: string]: any }> and Foo<any[]> are actually unrelated types as far as the type system is concerned. And while overloads are looser in their checks, they do expect the return type of the overload and the implementation to be related (Either the implementation return or the overloads return must be a subtype of the other, ex)

Why some changes make it work:

  • Turning off strictFunctionTypes will make the barCallback site for T bivariant, so Foo will be covariant
  • Converting barCallback to a method, makes the site for T bivariant so Foo will be covariant
  • Removing barCallback will remove the contravariant usage and so Foo will be covariant
  • Removing baz will remove the covariant usage of T making Foo contravariant in T.

Workarounds

You can keep strictFunctionTypes on and carve out an exception just for this one callback to keep it bivariant, by using a bivariant hack (explained here for a more narrow use case, but the same principle applies):


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

Playground Link

like image 157
Titian Cernicova-Dragomir Avatar answered Dec 19 '22 23:12

Titian Cernicova-Dragomir