Infer literal value from type in Typescript

I am wondering if there is a way to remove some redundancy / add DRYness in the following scenario.

Let's say I'm creating a super simple message hub — recipients can register for messages of a certain type and, subsequently, receive a copy whenever such messages arrive at the hub.

A simple implementation might look like this:

interface Message {
  name: "ping" | "pong";
}

interface PingMessage extends Message {
  name: "ping";
  value: string;
}

interface PongMessage extends Message {
  name: "pong";
  value: string;
}

type HubMessage = PingMessage | PongMessage;

type MessageHandler<T extends HubMessage> = (message: T) => any | Promise<any>;

class MessageHub {
  private recipients: Record<string, MessageHandler<any>[]> = {};

  public receive<T extends HubMessage>(
    name: T["name"],
    handler: MessageHandler<T>
  ) {
    this.recipients[name] = [...(this.recipients[name] ?? []), handler];
  }

  public send(message: HubMessage) {
    (this.recipients[message.name] ?? []).forEach((handler) =>
      handler(message)
    );
  }
}

const hub = new MessageHub();

// register a few handler
hub.receive<PingMessage>("ping", (m) => console.log(`pinging ${m.value}`));
hub.receive<PingMessage>("ping", (m) => console.log(`pinging too ${m.value}`));
hub.receive<PongMessage>("pong", (m) => console.log(`ponging ${m.value}`));

// start sending
hub.send({ name: "ping", value: "fly there" });
hub.send({ name: "pong", value: "and back" });

On the receive method, is there a way to remove the name argument and still get the value to be able to index the handler by it?

The code above enables autocompletion to figure out that the name must be ping for a PingMessage, so it feels a little silly to have to specify both the generic parameter and the name. I suspect, however, the information's completely out of reach at compile time?

Sandbox to play around with right here, any insights much appreciated!

Upvotes: 1

Views: 108

Answers (1)

Alex Wayne
Alex Wayne

Reputation: 186994

On the receive method, is there a way to remove the name argument and still get the value to be able to index the handler by it?

Nope.

The type information is completely lost at runtime. So you must rely on a value at runtime to in order to determine the type of the object so that you can access it safely.


What you can do is go the other way though. You can better infer the type from the name "ping". So you can remove the duplication by getting rid of the explicit type parameter instead.

To pull that off, we need to change how the generic parameter works.

  public receive<T extends HubMessage['name']>(
    name: T,
    handler: MessageHandler<HubMessage & { name: T }>
  )

Now instead of a whole HubMessage being the generic parameter, it's just the unique name of a HubMessage. From that you can derive the type of the exact HubMessage you want with an intersection:

HubMessage & { name: T }

This filters the union down to just what matches { name: T }.

Now you can do:

hub.receive("ping", (m) => console.log(`${m.name}ing ${m.value}`)); // m.name is "ping"
hub.receive("ping", (m) => console.log(`${m.name}ing too ${m.value}`)); // m.name is "ping"
hub.receive("pong", (m) => console.log(`${m.name}ing ${m.value}`)); // m.name is "pong"

And in each case m is strongly typed as the exact member of HubMessage that you want.

In general, it's best to make your generic parameters the simplest type that could possibly be used to derive more complex types. In this case that means just the name, and then using that name to find the right interfaces for the callback.

Working example

Upvotes: 1

Related Questions