Search code examples
javascripttypescriptgenericstypescript-genericsreturn-type

Depending Generic ResultType in Typescript


😊 Hi everyone!

interface Data {
  date: Date;
  message?: string;
}

interface DataB {
  dateB: Date;
  messageB?: string;
}

class DataResolver {
  public submit(): Data {
    return { date: new Date() };
  }

  public submitB(): DataB {
    return { dateB: new Date() };
  }
}

interface StringKeyedObject {
  [key: string]: any;
}

class Handler<C extends StringKeyedObject> {
  send<
    K extends Extract<keyof C, string>,
    L extends ReturnType<Extract<keyof K, object>>,
  >(eventName: K, arg: L) {
    //
  }
}
const handler = new Handler<DataResolver>();
handler.send('submit', null);

I really would like to set the second arg parameter depending on the first value paramter. In VS Code autocomplete suggests submit and submitB as intended. 😊 Now if I select submit I would like to be arg of type Data. If I select submitB the type should be DataB.

My attempt with generic type L extends ReturnType<Extract<keyof K, object>> does not work at all. Obviously.

I hope you'll find a solution! Thanks an all the best! 😊


Solution

  • I would write it like this:

    class Handler<C extends StringKeyedObject> {
      send<K extends Extract<keyof C, string>>(
        value: K, arg: C[K] extends (...args: any) => infer R ? R : never) {
        // do stuff
      }
    }
    

    Here the arg parameter type depends on K. It is essentially the same as ReturnType<C[K]>, so let's first look at that version first:

    class Handler<C extends StringKeyedObject> {
      send<K extends Extract<keyof C, string>>(
        value: K, arg: ReturnType<C[K]> ) {
        // do stuff
      }
    }
    

    The type C[K] is an indexed access type meaning "the property type you'd read when indexing into an object of type C with a key of type K". It is presumably what you were trying to do with keyof K, except that the keyof operator on K would give you "the keys of the keys of C", which is like, whatever keys a string has... toUppercase and such. Not what you wanted.

    And note that, unless you have a special reason, you don't need to have a separate type parameter for arg. You could have written L extends ReturnType<C[K]> and used L, but we don't need some arbitrary subtype of ReturnType<C[K]> for this to work, so there's no reason to add another type parameter.


    So, given this definition, let's make sure it works:

    class DataResolver {
      public submit(): Data { return { date: new Date() }; }
      public submitB(): DataB { return { dateB: new Date() }; }
      a = 2 // I added this
    }
    const handler = new Handler<DataResolver>();
    handler.send("submit", { date: new Date() }); // okay
    handler.send("submitB", { dateB: new Date() }); // okay
    handler.send("a", "wha") // <-- shouldn't compile but it does
    

    Everything works the way you want, except... if your DataResolver happens to have a non-function property, we don't want the compiler to accept any call to handler.send() for that property. One way to do that is to check whether the property value is a function type, and if so, use its return type; and if not, use the impossible never type. That brings us back to:

    class Handler<C extends StringKeyedObject> {
      send<K extends Extract<keyof C, string>>(
        value: K, arg: C[K] extends (...args: any) => infer R ? R : never) {
        // do stuff
      }
    }
    

    where arg is a conditional type that depends on C[K]. The use of infer lets us extract out the return type as its own type parameter R and use it.

    By the way, this is very similar to how ReturnType<T> is defined:

    type ReturnType<T extends (...args: any) => any> = 
      T extends (...args: any) => infer R ? R : any;
    

    except that this version gives the any type when T isn't a function, and since any accepts anything, the call to handler.send("a", "wha") succeeds when it shouldn't.

    Okay, let's test it one more time:

    const handler = new Handler<DataResolver>();
    handler.send("submit", { date: new Date() }); // okay
    handler.send("submitB", { dateB: new Date() }); // okay
    handler.send("a", "wha") // error!
    // -------------> ~~~~~
    // Argument of type 'string' is not assignable to parameter of type 'never'.
    

    Looks good. There are other ways to harden send against bad inputs (we could make it so that you're not even allowed to give it keys corresponding to non-functions) but I don't want to digress even further from the question as asked.

    Playground link to code