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.
This is at it's core an issue of variance. So first a variance primer:
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>
:
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; // ✅
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; // 🚫
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; // 🚫
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 :
T
is used in as a field or as the return type of a functionT
is used as the parameter of a function signature under strictFunctionTypes
T
is used in both a covariant and contravariant positionT
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).
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.
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:
strictFunctionTypes
will make the barCallback
site for T
bivariant, so Foo
will be covariantbarCallback
to a method, makes the site for T
bivariant so Foo
will be covariantbarCallback
will remove the contravariant usage and so Foo
will be covariantbaz
will remove the covariant usage of T
making Foo
contravariant in T
.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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With