1

I have a very simple messaging system implemented in TypeScript using implicit anys and am trying to type it properly so no type information is lost anywhere.

These messages are simple objects which carry some information used by handler functions. All messages have a message.type property, which is used to decide which handler function gets called.

There's a base interface Message which only defines this type property and then there are specific interfaces which extend from it.

I can't quite figure out how to type this properly, and am not sure what the exact issue is.

As it is, the compiler is failing with this error message:

Type '(message: MessageA) => void' is not assignable to type 'MessageHandler'.
  Types of parameters 'message' and 'message' are incompatible.
    Type 'T' is not assignable to type 'MessageA'.
      Property 'x' is missing in type 'Message' but required in type 'MessageA'.

Here is a simplified version of the code that can reproduce the issue:

export enum MessageType {
  MessageTypeA,
  MessageTypeB,
}

export interface Message {
  readonly type: MessageType
}

export interface MessageA extends Message {
  readonly type: MessageType.MessageTypeA
  readonly x: string
}

export interface MessageHandler {
  <T extends Message>(message: T): void
}

const onMessageA: MessageHandler = (message: MessageA) => {
  console.log(message.x)
}

There are other parts to the messaging system but are not directly relevant, I believe.

Due to how the rest of the system works, I need TS to infer the generic type. I can't, for example, declare MessageHandler as follows:

export interface MessageHandler<T extends Message> {
  (message: T): void
}

Tried this code with TypeScript 3.8.3 and 3.9.2.

Here's a link to this code in the TypeScript Playground: link.

I also tried declaring MessageHandler as follows, yet got the same error:

export type MessageHandler = <T extends Message>(message: T) => void

How can I properly type MessageHandler so it can accept any kind of message as long as it has a type property, without needing to explicitly pass the type when called?

EDIT

Adding some context, I'm using the MessageHandler like this:

const defaultFallback = <T extends Message>(message: T) => console.warn('Received message with no handler', message)


export type MessageHandlers = {
  readonly [P in MessageType]?: MessageHandler;
}

export const makeHandler = (functions: MessageHandlers, fallback: MessageHandler = defaultFallback) => (message: Message) => {
  if (!message)
    return

  const handler = functions[message.type]

  if (handler)
    handler(message)
  else if (fallback)
    fallback(message)
}

const onMessageA: MessageHandler = (message: MessageA) => {
  console.log(message.x)
}

const onMessageB: MessageHandler = (message: MessageB) => {
  ...
}

makeHandler({
  [MessageType.MessageA]: onMessageA,
  [MessageType.MessageB]: onMessageB,
})

3
  • What you're asking for isn't type safe or I don't understand the use case. If onMessageA should "accept any kind of message as long as it has a type property" then its implementation is bad because it will explode for most message types. If onMessageA should only accept a MessageA argument, then its annotated type is bad and you need to use something like the version you say you can't use, where MessageHandler<T> is itself generic. Why can't you use it? Can you include an example that shows why that doesn't work for you? Commented May 13, 2020 at 2:07
  • I mean, this is sort of what I'd expect to see here; a mapping from all the enum values to all the handlers, producing a single handler that works for all types. If that's not what you're going for then you might want to elaborate. If it is what you're going for I can write that up as an answer if it helps. Good luck! Commented May 13, 2020 at 2:32
  • @jcalz that is what I'm going for, please do write it as an answer :) Commented May 14, 2020 at 0:00

1 Answer 1

2

What you're asking for isn't type safe and you'll end up needing a lot of any or other type assertions to get it to work. The problem is that onMessageA and onMessageB really only accept messages of type MessageA and MessageB respectively; if you try to annotate them as a type that should "accept any kind of message as long as it has a type property", you will get a compiler warning and rightly so. The correct type for those handler is the version you say you can't use, where MessageHandler<T> is itself generic:

export interface MessageHandler<T extends Message> {
  (message: T): void;
}

Then you can annotate them yourself:

const onMessageA: MessageHandler<MessageA> = message => {
  console.log(message.x);
};

or you can write a helper function that lets the compiler infer the T for you:

// helper function for type inference
const oneHandler = <T extends Message>(h: MessageHandler<T>) => h;

// onMessageA will be inferred as a MessageHandler<MessageA>:
const onMessageA = oneHandler((message: MessageA) => {
  console.log(message.x);
});

Since your use case involves building a handler that can truly handle anything from some discriminated union of Message types out of a bunch of handlers for each member in the union, you can use the generic MessageHandler<T> to describe that process. First we need the full discriminated union as a type:

type Messages = MessageA | MessageB;

Then we can write a makeHandler() function that takes a mapping from MessageType to individual handlers:

function makeHandler(
  map: {
    [P in Messages["type"]]: MessageHandler<Extract<Messages, { type: P }>>
  }
): MessageHandler<Messages> {
  return <M extends Messages>(m: M) => (map[m.type] as MessageHandler<M>)(m);
}

The input type [P in Messages["type"]]: MessageHandler<Extract<Messages, { type: P }>> is equivalent to

{
  [MessageType.MessageTypeA]: MessageHandler<MessageA>;
  [MessageType.MessageTypeB]: MessageHandler<MessageB>;
};

which is what you want to pass in. And the output type MessageHandler<Messages> is a handler for the full union.

The implementation at runtime will look like m => map[m.type](m). Now, it would be great if the compiler could verify that this is type safe, but unfortunately it's too high-order of an inference for it to make. The compiler will see map[m.type] as a union and m as another union and treats them as if they were independent, but of course they're not. The compiler is worried about map[m.type] possibly being a MessageHandler<MessageA> while m is a MessageB, even though that's not possible. I filed microsoft/TypeScript#30581 about this general class of problem; there's no great answer here, unfortunately. Usually I just use a type assertion like map[m.type] as MessageHandler<M> and move on. The other option is to use a redundant but type-safe implementation:

return (m: Messages) => m.type === MessageType.MessageTypeA ? map[m.type](m) : map[m.type](m);

Anyway that means you should be able to create and use a full handler without having to manually annotate the particular handler types, either by using existing individual handlers created by oneHandler(), or by building them in the object passed to fullHandler() directly:

const fullHandler = makeHandler({
  [MessageType.MessageTypeA]: m => console.log(m.x),
  [MessageType.MessageTypeB]: m => console.log(m.y)
});

fullHandler({ type: MessageType.MessageTypeA, x: "" }); // okay
fullHandler({ type: MessageType.MessageTypeA, y: "" }); // error
fullHandler({ type: MessageType.MessageTypeB, x: "" }); // error
fullHandler({ type: MessageType.MessageTypeB, y: "" }); // okay

Looks good to me. Okay, hope that helps; good luck!

Playground link to code

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.