Reputation: 567
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
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.
Upvotes: 1