node.jstypescript.d.ts

How to set types based on a value of a property in another parameter?


How should I set a type based on a value of a property of another parameter in a method? I'm relatively new to TypeScript.

This is what I tried:

class SomeClass {
    // constructor, other properties, etc
    listen<K extends keyof ClientEvents>({ event: K }: ClientEvent, listener: (...args: ClientEvents[K]) => any)
}

Here, ClientEvents looks like this:

interface ClientEvents {
    "event-1": [data: number]
    "event-2": [data: number]
    "event-3": [data: any]
}

And ClientEvent (no s) looks like this:

interface ClientEvent {
    event: keyof ClientEvents
    anotherProperty: any // I don't want to share all the properties
}

Currently, the event field does autofill to event-1, but the first parameter to the function shows up as type any, but it should be type number. How can I fix this without needing more parameters?

When changing one of the datas from inside of ClientEvents to type string, it shows up as type string | number.

class SomeClass {
  listen<K extends keyof ClientEvents>({ event: K }: ClientEvent, listener: (...args: ClientEvents[K]) => any): void;
}

interface ClientEvents {
  "event-1": [data: number]
  "event-2": [data: string]
}

interface ClientEvent {
  event: keyof ClientEvents
  anotherProperty: any
}

new SomeClass().listen({ 
  event: "event-1", 
  anotherProperty: 1 
}, (arg) => { 
  arg // type is string | number
})

Here is a Playground Link


Solution

  • See the FAQ entry on destructuring assignment and type annotations for a possibly-authoritative answer.

    Your problem is that the K in { event: K }: ClientEvent isn't what you think it is. In JavaScript, if you have a function parameter like { event: K }, you are using destructuring assignment with variable renaming, so you will copy the event property of that function argument into a variable named K:

    function foo({event: K}: ClientEvent) {
      // variable -----> ^
      console.log(K.toUpperCase()); 
      //          ^ <---- see?      
    }
    foo({event: "event-1", anotherProperty: 123})  // EVENT-1
    

    Because JavaScript supports this, so does TypeScript (as you can see by the ClientEvent type annotation), and therefore the call signature itself just uses K as a dummy parameter name that it ignores.

    And of course, that K cannot be used as an inference site for the type parameter K of the same name, since they are unrelated.


    You were attempting to use K as a type annotation for the event property. But that's not supported. In fact there is currently no way to put type annotations inside the destructured object itself. There is an open feature request at microsoft/TypeScript#29526 asking for some way to do this (maybe... double colon? like {event::K}) but for now it's impossible.

    If you want to give a type to the event property, you will have to do it in the actual type annotation, after the destructured object:

    { event }: { event: K }
    

    And if you want to preserve the fact that the whole thing needs to be a ClientEvent, you will need to express that with something like an intersection type

    { event }: { event: K } & ClientEvent
    

    Or possibly by extending your ClientEvent interface if that looks nicer:

    interface ClientEventFor<K extends keyof ClientEvents> extends ClientEvent {
      event: K
    }
    
    declare class SomeClass {
      listen<K extends keyof ClientEvents>(
        { event }: ClientEventFor<K>,
        listener: (...args: ClientEvents[K]) => any): void;
    }
    

    And then things will start working for you:

    new SomeClass().listen(
      { event: "event-1", anotherProperty: 1 },
      (arg) => { arg.toFixed() } // okay, arg is number here
    )
    

    Playground link to code