1

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!

Kleywalker
  • 49
  • 5
  • Welcome to Stack Overflow! Please [edit] the code to be a [mre] without errors or issues *unrelated* to your question. It looks like `Data` and `DataB` should be *interfaces* and not classes (unless you are going to construct them with `new`, in which case you shouldn't be making them with object literals) and you have at least one typo in `submitB`. This will make it so that people can easily work on your issue without being distracted by having to fix things that aren't your issue first. – jcalz Mar 30 '23 at 20:19
  • Once you do that: Does [this approach](https://tsplay.dev/NdvjdW) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz Mar 30 '23 at 20:22
  • Hi. :) I edited the code above. `Data` and `DataB` have to be classes. Changing this in your approach seems to do the trick. Thanks for that! But how? How does this work? Could you explain your solution? – Kleywalker Mar 31 '23 at 12:00
  • They may “have to be classes” in your actual code, but in this example they don’t have to be classes, and having them as classes is distracting because: you’re not initializing required properties; and you’re returning object literals in methods that claim to return class instances, so `instanceof` won’t behave as expected. Unless you’re trying to ask about that stuff, please [edit] to make them interfaces for the purposes of this question. Again if you want classes in your actual code that’s fine, but this question should benefit future readers as well. – jcalz Mar 31 '23 at 12:17
  • I can see that. Will change for future benefits. Could you then explain the generic expression? – Kleywalker Mar 31 '23 at 13:41
  • Yes, I will write up an answer explaining when I get a chance. – jcalz Mar 31 '23 at 13:43

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is great! Thanks @jcalz for the solution and the answer! Thanks for your time to explain it in detail. This will help a lot! – Kleywalker Mar 31 '23 at 16:15