I'm trying to understand the following examples from the Typescript advanced types handbook.
Quoting, it says that:
The following example demonstrates how multiple candidates for the same type variable in co-variant positions causes a union type to be inferred:
type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type T10 = Foo<{ a: string, b: string }>; // string
type T11 = Foo<{ a: string, b: number }>; // string | number
Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>; // string & number
My question is: why are the object properties from the first example considered "co-variant positions" while the second function arguments are considered "contra-variant positions"?
Also the second example seems to resolve to never not sure if there's any config required to get it to work.
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.
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