jcalz' solution to this question is [as always] perfect, however, for those who might not fully understand it, here's a bit more explanation on this quote in his answer:
Multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.
The above tip is actually the main trick used in the definition of the UnionToIntersection
type, however, the term "contra-variant" was vague to me and Googling didn't give helpful results either, so let's go over it here:
According to Wikipedia:
Many programming language type systems support subtyping. Variance refers to how subtyping between more complex types relates to subtyping between their components. For example, how should a list of Cats relate to a list of Animals? Or how should a function that returns Cat relate to a function that returns Animal?
Let's see the above explanation in code:
type A = (a: Animal) => void
type C = (d: Cat) => void
declare let aFunc: A;
declare let cFunc: C;
Now which of the below assignments do you expect to be correct?
cFunc = aFunc // 🚩 [ ] ✅ [ ]
aFunc = cFunc // 🚩 [ ] ✅ [ ]
First make a guess, then read on. :)
Opposite of what we might expect from "Inheritance", where values of a subtype could be assigned to a variable of their supertype, this is not true in the same direction when this variable is a parameter of a function type, and we're assigning "functions" (Wikipedia's intended "complex type"). And interestingly, it is indeed correct in the opposite direction! I.e., cFunc = aFunc
is the correct one in the snippet above, and aFunc = cFunc
is wrong.
Now let's see why cFunc = aFunc
is the correct one. The reason it is correct is, when we assign some variable of type Y to some variable of type X, it would be "correct" only if the new type (Y in this example) doesn't break any of the usages of the old type (X in this example). For example:
a = new Animal()
c = new Cat()
a = c // ✅ Not breaking, everywhere an Animal is used, a Cat must be useable too
// (It is also formally known as the "Liskov Substitution Principle".)
a.eat() // ---> c.eat() ✅ No problem, Cats can eat too
Now use this same rule in case of the function types: If you are assigning a function foo
of function type Foo
, to a variable bar
of function type Bar
, then wherever you've used bar
, it must remain still useable / valid.
declare let foo: (param: Animal): void
declare let bar: (param: Cat): void
a = new Animal()
c = new Cat()
// valid usages of foo:
foo(a) // ✅
foo(c) // ✅
// valid usage of bar:
bar(c) // ✅
foo = bar // ❌ wrong because 👇
foo(a) // ❌ this one has not remained useable / valid
// because foo expects a Cat now, but is receiving an Animal, which is not valid
foo(c) // ✅
bar = foo // ✅ correct because 👇 all usages of bar remains still useable / valid
bar(c) // bar expects an Animal now, and has received a Cat, which is still valid
// ⭐ That's why we say function parameter is a **contra-variant** position for
// a type, because it reverses the direction of the assignability.
So now we can understand why cFunc = aFunc
is the correct choice!
The fun edge case of this is, you can type a function parameter as never
and that allows you to assign functions with whatever type for that parameter to it:
type Foo = (a: never) => void
type Bar = (a: Function) => void
type Baz = (a: boolean) => void
type Qux = (a: SuperComplexType) => void
declare let foo: Foo
declare let bar: Bar
declare let baz: Baz
declare let qux: Qux
foo = bar // ✅
foo = baz // ✅
foo = qux // ✅
A summary of all the three co/contra/in variances using the same Cat and Animal example is:
- Covariance:
() => Cat
is assignable to () => Animal
, because Cat is assignable to Animal; It "preserves the direction of the assignability".
- Contravariance:
(Animal) => void
is assignable to (Cat) => void
, because something that expects an Animal can also take a Cat; It "reverses the direction of the assignability".
- Invariance:
(Animal) => Animal
is not assignable to (Cat) => Cat
, because not all returned Animals are Cats, and (Cat) => Cat
is not assignable to (Animal) => Animal, because something expecting a Cat cannot take any other kind of Animal.
Now this is how jcalz' UnionToIntersection
works:
type FirstHalfOfUnionToIntersection<U> = U extends any ? (k: U)=>void : never
This is a distributed conditional (because the type U
before extends
is a naked type (appears alone and is not part of some more complex type expression)), so runs the conditional for each of the components of the union, e.g., in case of X | Y | Z
, it produces ((k: X) => void) | ((k: Y) => void) | ((k: Z) => void)
.
On the second half of the type, it's actually doing this:
<A_union_of_some_functions_from_first_half> extends ((k: infer I)=>void) ? I : never
This is again a distributed conditional, however, here's the interesting part: The type I
that is being inferred is in a contra-variant position (it is a function parameter), so all possible inferences of it will be intersected!
Multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.
E.g., continuing on the same X | Y | Z
example, the result will be X & Y & Z
.