Home > Enterprise >  Why indexed access type using generics resolves to intersection?
Why indexed access type using generics resolves to intersection?

Time:12-16

In one of the vidoes the author noted that here:

interface MyMouseEvent {
  x: number;
  y: number;
}

interface MyKeyboardEvent {
  key: string;
}

interface MyEventObjects {
  click: MyMouseEvent;
  keypress: MyKeyboardEvent;
}

function handleEvent<K extends keyof MyEventObjects>(
  eventName: K,
  callback: (e: MyEventObjects[K]) => void
) {
  if (eventName === 'click') {
    callback({ x: 0, y: 0 }); // ERROR
  } else if (eventName === 'keypress') {
    callback({ key: 'Enter' }); // ERROR
  }
}

The type of e parameter is resolved as MyMouseEvent & MyKeyboardEvent. IMHO this is true, if you hover the error it says:

Type '{ x: number; y: number; }' is not assignable to type 'MyMouseEvent & MyKeyboardEvent'

Then he also noted that if we had defined the callback in following way for example:

  ....
  callback: (e: MyEventObjects[keyof MyEventObjects]) => void

Then type of e becomes MyMouseEvent | MyKeyboardEvent

Does someone have explanation why is type of e different depending if we used MyEventObjects[K] vs MyEventObjects[keyof MyEventObjects]? Especially since K extends keyof MyEventObjects.

CodePudding user response:

The relevant equivalence from type theory is if T extends U then F<T & U> = F<T> | F<U> and F<T | U> = F<T> & F<U>. This is what we mean when we say "functions are contravariant in their parameters".

So for K extends keyof MyEventObject is the T extends U relationship, and your callback is the F. And when we look at the code:

function handleEvent<K extends keyof MyEventObjects>(
  eventName: K,
  callback: (e: MyEventObjects[K]) => void
) {
  if (eventName === 'click') {
    callback({ x: 0, y: 0 }); // ERROR
  } else if (eventName === 'keypress') {
    callback({ key: 'Enter' }); // ERROR
  }
}

and we think about what arguments we can pass to that function that satisfy the types:

handleEvent('click', (e: MyMouseEvent) => {});
handleEvent('keypress', (e: MyKeyboardEvent) => {});

We see that the callback parameter is the union of functions F<T> | F<U> (I'm fudging that it's an index type, doesn't matter here). By the equivalence above we see that union type is equivalent to F<T & U> which is why calling the callback fails: there is no parameter that can satisfy that type.

So we change to not use the generic to index:

function handleEvent<K extends keyof MyEventObjects>(
  eventName: K,
  callback: (e: MyEventObjects[keyof MyEventObjects]) => void
) {
  if (eventName === 'click') {
    callback({ x: 0, y: 0 });
  } else if (eventName === 'keypress') {
    callback({ key: 'Enter' });
  }
}

but now we have a new problem. The type of callback is now the same as F<KeyEvent | MouseEvent> because the access is no longer generic (meaning the callback function is now a straight union type on the parameter) and so the call doesn't error, but by the equivalence above that's the same as F<KeyEvent> & F<MouseEvent> (because objects are covariant in their keys) and when we go to call handleEvent...

handleEvent('click', (e: MyMouseEvent) => {}); // ERROR
handleEvent('keypress', (e: MyKeyboardEvent) => {}); // ERROR

No function can satisfy! This is the intersection of function types mentioned by others, and you can see it implied here by the equivalence from the beginning. Hope that clarifies: I know this isn't the most intuitive thing in the world.

Playground

CodePudding user response:

The example doesn't work because Typescript can't narrow the type of the callback parameter based on the value of the eventName parameter. The only way that callback can be safely called is with an argument that satisfies both of its possible parameter types.


If you intersect function types you get one function with a parameter type that is the union of the two input functions' parameter types, and vice versa.

For example, unioning these two functions types:

type A = (arg: string) => void;
type B = (arg: number) => void;

type C = A & B;

results in type C that is equivalent to type C = (arg: string | number) => void.

Similarly, if you intersected (type C = A | B;) then you would get type C = (arg: string & number) => void (or, in this case, (arg: never) => void, because the compiler has worked out that no value can be both a string and a number at the same time.


It's not obvious but writing

callback: (e: MyEventObjects[K]) => void

is the same as writing

callback: ((e: MyEventObjects['click']) => void) | ((e: MyEventObjects['keypress']) => void)

which, from my previous edit about contravariance, is the same as

callback: (e: MyEventObjects['click'] & MyEventObjects['keypress']) => void

That's where the union and intersection come in: from the unconstrained K key. I know that your code does constrain it with the if-statement, but that's where my earlier point about narrowing comes in; Typescript doesn't know that K has been constrained at this point.

  • Related