These questions, along with others linked to them, focus on the creation of an intersection type that includes all keys.
I recently came across a fantastic blog post discussing this issue, and I was impressed by TypeScript's type system, which is more versatile than I had originally thought.
The crux of the matter is this: TypeScript's type system treats the types of matching attributes in union types as an optional intersection of those attribute types, or more precisely, a power set of intersections and additional arbitrary permutations. The optionality of an attribute's intersected types is indicated by uniting the surrounding object, while optional intersections require compatible types to be an intersection of all attributes (as explained below).
In terms of terminology: covariant types accept a typecast or value of a more specific subtype (extended type), contravariant types accept typecasts or values of a more general supertype (base type), and invariant types do not allow for any polymorphic behavior (neither subtypes nor supertypes are accepted).
Why doesn't the example using keyof Video.urls
from the blog work as expected? Let's explore: If we were to create a manual type clone where keyof
would return a union type to represent the power set of intersections for the given attribute, we might try
type Video2 = /*... &*/ { urls: { [key: keyof Video.urls] : string } }
which, due to semantics, would differ from what is actually desired:
type Video2 = /*... &*/ { urls: { [key: keyof Format320.urls] : string } } | { urls: { [key: keyof Format480.urls] : string}} | ...
.
The first approach would fail if an instance of Video2
is assigned to a variable of type Video
(as outlined in the blog). As a simpler example,
{ attr: A | B | (A & B) | (B & A) }
is distinct from
{attr: A} | {attr: B}
.
And why is that so? Type-safety rules dictate that a parameter type of a function, eponymous attributes in union types, or a generic input type are contravariant types (in terms of their position). This means that such a type specifies the most specific type it can accommodate, rather than the least specific one.
Optional attributes inherently fall under the covariant category since they specify a single supertype for all cases. This explains why they require a different syntax to define them.
As for the issue highlighted in the blog: applying a union of intersections to the urls
attribute would result in a contravariant type, whereas a covariant type is needed in this case.
What does work effectively is assigning a value of type
{ attr: (A & B) | (B & A) }
or
{ attr: A & B }
to a destination type of
{attr: A}|{attr: B}
!
And this is achieved through the use of the
infer
keyword: obtaining the smallest type that aligns with the power set of intersections. It must be a subtype of all intersections because the type is in a contravariant position (i.e., it must be a subtype), and only the intersection of all components forms a complete subtype of the power set of intersections.
A dilemma arises when considering that keyof
is intended to represent all conceivable key arrangements of a type, but there is no straightforward way for it to yield a covariant type for a contravariant type position. Such a scenario would clash with user expectations.
The practical solution in Typescript comes in the form of
<Name> in keyof <type>
, operating like an iterator over
<type>
. This feature allows you to utilize constructs like
{ [Name in keyof Type] : { Name : number; key : Name }}
, offering a sensible approach.
It would be beneficial if there were options to introduce optional intersections as operators.
For instance:
{ attr: (A &? B) | (B &? A & C)}
, signifying A and optionally B or A and optionally B and (non-optionally) C.