typescripttypescript-generics

Narrowing generic type from function argument


I need to cover with types this code:

interface MessageData {
  offscreen: {
    start: { streamId: string }
    setVolume: { volume: string }
    stop: void
  }
  service_worker: {
    test: { testData: number }
  }
}

function addEventListener(msg) {
  if (msg.target !== 'offscreen') return

  if (msg.action === 'start') start(msg.data.streamId)
  else if (msg.action === 'setVolume') setVolume(msg.data.volume)
  else if (msg.action === 'stop') stop()
}

And this is what I came to

function addEventListener<T extends keyof MessageData, A extends keyof MessageData[T], D extends MessageData[T][A]>(msg: { target: T, action: A, data: D }) {
  if (msg.target !== 'offscreen') return

  if (msg.action === 'start') start(msg.data.streamId)
  else if (msg.action === 'setVolume') setVolume(msg.data.volume)
  else if (msg.action === 'stop') stop()
}

For example, this code works, and the sendMessage method correctly resolves types when using the function.

function sendMessage<T extends keyof MessageData, A extends keyof MessageData[T], D extends MessageData[T][A]>(target: T, action: A, data?: D) {
  return chrome.runtime.sendMessage({ target, action, data })
}

And I don't understand why this doesn't happen in addEventListener.


Solution

  • In order to get narrowing when you check .action (so that TypeScript knows that msg.data.streamId exists when msg.action === 'start', for example), msg needs to be typed as a concrete discriminated union, like this:

    type Message = {
      target: 'offscreen'
      action: 'start'
      data: { streamId: string }
    } | {
      target: 'offscreen'
      action: 'setVolume'
      data: { volume: string }
    } | {
      target: 'offscreen'
      action: 'stop'
      data: void
    } | {
      target: 'service_worker'
      action: 'test'
      data: { testData: number }
    }
    

    To avoid repeating yourself you could derive that type from MessageData using nested mapped types, like so:

    type Message = {
      [T in keyof MessageData]: {
        [A in keyof MessageData[T]]: {
          target: T
          action: A
          data: MessageData[T][A]
        }
      }[keyof MessageData[T]]
    }[keyof MessageData]
    

    The indexed access operation at the end of each of those mapped types ([keyof MessageData[T]] and [keyof MessageData]) pulls out a union of the property value types, so at the end Message is a flat union type as shown above.

    Here's a complete playground demonstrating this.