Your observation that one of the examples resolves to never
is accurate and you are not missing any compiler settings. In newer versions of TS, intersections of primitive types resolve to never
. If you revert to an older version you will still see string & number
. In newer version you can still see the contravariant position behavior if you use object types:
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T21 = Bar<{ a: (x: { h: string }) => void, b: (x: { g: number }) => void }>; // {h: string; } & { g: number;}
Playground Link
As to why are function parameters contravariant while properties are covariant, it's a tradeoff between type safety and usability.
For function arguments, it is easy to see why they would be contravariant. You can only call a function safely with a subtype of the argument, not a base type.
class Animal { eat() { } }
class Dog extends Animal { wof() { } }
type Fn<T> = (p: T) => void
var contraAnimal: Fn<Animal> = a => a.eat();
var contraDog: Fn<Dog> = d => { d.eat(); d.wof() }
contraDog(new Animal()) // error, d.wof would fail
contraAnimal = contraDog; // so this also fails
contraAnimal(new Dog()) // This is ok
contraDog = contraAnimal; // so this is also ok
Playground Link
Since Fn<Animal>
and Fn<Dog>
are assignable in the opposite direction as two variables of types Dog
and Animal
would be, the function parameter position makes Fn
contravariant in T
For properties, the discussion as to why they are covariant is a bit more complicated. The TL/DR is that a field position (ex { a: T }
) would make the type actually invariant, but that would make life hard so in TS, by definition a field type position (such as T
has above) makes the type covariant in that field type( so { a: T }
is covariant in T
). We could demonstrate that for the a
is read-only case, { a: T }
would covariant, and for the a
is write-only case { a: T }
would be contravariant, and both cases together give us invariance, but I'm not sure that is strictly necessary, instead, I leave you with this example of where this covariant by default behavior can lead to correctly typed code having runtime errors:
type SomeType<T> = { a: T }
function foo(a: SomeType<{ foo: string }>) {
a.a = { foo: "" } // no bar here, not needed
}
let b: SomeType<{ foo: string, bar: number }> = {
a: { foo: "", bar: 1 }
}
foo(b) // valid T is in a covariant position, so SomeType<{ foo: string, bar: number }> is assignable to SomeType<{ foo: string }>
b.a.bar.toExponential() // Runtime error nobody in foo assigned bar
Playground Link
You might also find this post of mine on variance in TS interesting.