Why TypeScript cannot infer callback argument type based on generics

Issue

I was expecting TypeScript to infer callback argument type as number in the following example but I’m getting an error.

type EventWatch = {
  <K extends keyof User>(eventName: `${string & K}Changed`, callback: (e: User[K]) => void): void;
};

type User = {
  firstname: string;
  lastname: string;
  age: number;
  on: EventWatch;
}

let john: User = {
  firstname: 'John',
  lastname: 'Doe',
  age: 33,
  on(eventName, callback) {
    if (eventName == 'ageChanged') {
      callback(133) //ERROR:
      /*
    TS2345: Argument of type 'number' is not assignable to parameter of type 'User[K]'.
    Type 'number' is not assignable to type 'never'.
       */
    }
  }
};

It correctly infers the eventName type and recognizes it should be one of three values: "firstnameChanged", "lastnameChanged", "ageChanged". But it cannot figure out its value.

Arguments that I pass to the function are correctly resolved and it gives an error if I send a wrong argument as it should.

john.on('ageChanged', e => {}); //No Error
john.on('salaryChanged', e => {}); //Error: There is no such a key named salary.

Why it won’t infer the callback argument type?

Solution

The problem is that TypeScript does not use control flow analysis to narrow or constrain generic type parameters. So when you check eventName == 'ageChanged', while the type of the eventName variable might be narrowed, nothing will happen to K. Before the check, the compiler knows that K extends keyof User, and the check does nothing to change this. In particular, K is not re-constrained to K extends "age" (and in fact such a narrowing would not be safe, since maybe K is the full keyof User union type). And therefore callback of type (e: User[K]) => void is not known to be of type (e: number) => void, and you get an error:

on(eventName, callback) {
  // (parameter) eventName: `${K}Changed`
  // (parameter) callback: (e: User[K]) => void
  if (eventName == 'ageChanged') {
    // (parameter) eventName: `${K}Changed` still
    // (parameter) callback: (e: User[K]) => void still
    callback(133) // error
  }
}

There are several feature requests in GitHub to allow some way to narrow generic type parameters based on control flow. See microsoft/TypeScript#24085 and microsoft/TypeScript#27808 for some of them (and they link to others). Who knows when or even if this will be implemented. For now, the easiest change is to suppress the error with a type assertion to tell the compiler what it doesn’t know:

on(eventName, callback) {
  if (eventName == 'ageChanged') {
    (callback as (e: number)=>void)(133) // okay
  }
}

But this isn’t type safe any longer.


Instead of using generics here, you might consider giving on() a rest parameter of a union of tuples, because there are only a finite list of possible allowable eventName/callback pair types. And because the eventName parameter is a string literal type, that means your union will be a discriminated union where checking eventName will automatically narrow callback as well.

The idea is something like this:

type EventWatchParams = 
  [eventName: "firstnameChanged", callback: (e: string) => void] | 
  [eventName: "lastnameChanged", callback: (e: string) => void] | 
  [eventName: "ageChanged", callback: (e: number) => void] | 
  [eventName: "onChanged", callback: (e: EventWatch) => void];

type EventWatch = {
  (...args: EventWatchParams): void;
};

Note how this means you can only call an EventWatch with two parameters where the first and second parameter types depend on each other. Of course you don’t want to write out EventWatchParams manually; you’d like the compiler to compute it in terms of User automatically. Well, you can do so via a distributive object type (as coined in ms/TS#47109) where you immediately index into a mapped type, like this:

type EventWatchParams = {
  [K in keyof User]: [eventName: `${K}Changed`, callback: (e: User[K]) => void]
}[keyof User]

You can verify that EventWatchParams defined this way is equivalent to the union from before.


So, how does it work with this discriminated-union-of-rest-tuples version? Well, with TypeScript 4.5 and below you will need to use ...args and index into it with numeric indices. It’s a little ugly, but it is verified as type safe:

on(...args) {
  if (args[0] == 'ageChanged') {
    args[1](133) // okay
  }
}

The narrowing works; if args[0] is ageChanged, then args[1] is narrowed to (e: number) => void.

Once TypeScript 4.6 lands, you can improve this a little by destructuring args into your desired variable names and the control flow analysis will still work:

// TS4.6+
on(...args) {
  const [eventName, callback] = args;
  if (eventName == 'ageChanged') {
    callback(133) // okay
  }
}

This is about as good as you can get, though. The ideal version where you write on(eventName, callback) instead of on(...args) is waiting on microsoft/TypeScript#46680 and it’s not clear if that will be addressed anytime soon.

Playground link to code

Answered By – jcalz

Answer Checked By – Marie Seifert (AngularFixing Admin)

Leave a Reply

Your email address will not be published.