rbhalla
rbhalla

Reputation: 1189

Dynamically use union type to define callback function

I am trying to write the equivalent of a message subscription function. A simplified version without types looks like this:

function newMessage(message) {
  postMessage(message)
}

function subscribe(messageType, callback) {
  handleNewMessage(message => {
    if (message.type === messageType) {
       callback(message.data)
    }
  })
}

newMessage's message can be multiple types. Therefore I'm defining them separately, then using a discriminated union to type the message argument. An example:

type FooMessage = {
  type: 'foo', 
  data: {
    a: string,
    b: number
  }
}

type BarMessage = {
  type: 'bar'
}

type MessageType = FooMessage | BarMessage

Note how BarMessage does not have any data associated.

This allows my newMessage function be typed like this:

  function newMessage(message:MessageType):void {
    postMessage(message)
  }

I am not entirely sure how to type subscribe though. This is what I have so far:

function subscribe(messageType: MessageType['type'], callback) {
  handleNewMessage(message => {
    if (message.type === messageType) {
       callback(message.data)
    }
  })
}

I am not entirely sure how to type callback. If I do MessageType['data'] I get an error since data isn't always present. Even if I did define data:undefined on BarMessage, I have lost the link between the message type and the data.

Ideally, I would like to be able to write subscribe('bar', (data) => console.log(data)) and typescript to know that data is actually undefined in this case.

One option I can see is to declare a function type multiple times for each message type I have, but this feels overly verbose since it means for each message type, I'm defining the MessageType and the associated subscribe function.

In reality, I have many many more messages and would prefer not to do this.

What is the best way to type subscribe's callback function?

Upvotes: 1

Views: 115

Answers (1)

superhawk610
superhawk610

Reputation: 2653

You can use the following:

  • a generic type parameter T so that TypeScript can narrow the message type if it's statically known
  • a distributive conditional type with Extract which allows narrowing to a particular member type of a distributed union, see this SO thread for more details
  • a conditional type to extract the type of data from a message only when its present as a key
type TypedMessage<T extends MessageType['type']> = Extract<MessageType, { type: T }>;

type MessageData<M extends MessageType> = M extends { data: any } ? M['data'] : undefined;

function subscribe<T extends MessageType['type']>(
  messageType: T,
  callback: (data: MessageData<TypedMessage<T>>) => void
) {
  handleNewMessage(message => {
    if (message.type === messageType) {
       callback((message as { data?: any }).data)
    }
  })
}

subscribe('foo', (data) => console.log(data)); // typeof data === FooMessage['data']
subscribe('bar', (data) => console.log(data)); // typeof data === undefined

Here's a playground link TypeScript Playground

Upvotes: 2

Related Questions