Typescript union type of functions weirdly becomes an intersection type of the arguments
Asked Answered
H

1

5

I'm trying to model my data in Typescript in the following manner. Main focus is the type MessageHandler that maps the type in a message with a callback that accepts that message. I followed the handbook chapter for mapped types

type BaseMessageBody = {
    msg_id?: number;
    in_reply_to?: number;
};

type BaseMessage<TBody> = {
    src: string;
    dest: string;
    body: BaseMessageBody & TBody;
};

type BroadcastMessage = BaseMessage<{
    type: 'broadcast';
    message: number;
}>;

type InitMessage = BaseMessage<{
    type: 'init';
    node_id: string;
    node_ids: string[];
}>;

type Message = BroadcastMessage | InitMessage;

type Body = Message['body'];

type MessageHandler = {
    [M in Message as M['body']['type']]?: (message: M) => void;
};

So far so good. The expanded type of MessageHandler is

type MessageHandler = {
    broadcast?: ((message: BroadcastMessage) => void) | undefined;
    init?: ((message: InitMessage) => void) | undefined;
}

But when I try to actually use the type like below:

const handlers: MessageHandler = {};

export const handle = (message: Message) => {
    if (message.body.type === 'init') {
        console.log('Initialized');
    }
    const handler = handlers[message.body.type];
    if (!handler) {
        console.warn('Unable to handle type', message.body.type);
        return;
    }
    handler(message); //Error here
};

I get the following error. Somehow the handler type has transformed to const handler: (message: BroadcastMessage & InitMessage) => void

error: TS2345 [ERROR]: Argument of type 'Message' is not assignable to parameter of type 'BroadcastMessage & InitMessage'.
  Type 'BroadcastMessage' is not assignable to type 'BroadcastMessage & InitMessage'.
    Type 'BroadcastMessage' is not assignable to type 'InitMessage'.
      Types of property 'body' are incompatible.
        Type 'BaseMessageBody & { type: "broadcast"; message: number; }' is not assignable to type 'BaseMessageBody & { type: "init"; node_id: string; node_ids: string[]; }'.
          Type 'BaseMessageBody & { type: "broadcast"; message: number; }' is missing the following properties from type '{ type: "init"; node_id: string; node_ids: string[]; }': node_id, node_ids
        handler(message);
                ~~~~~~~

There are slightly related questions on stack overflow but I wasn't able to resolve my problem by following them. Here is the playground with all the code.

Honey answered 6/5, 2023 at 10:12 Comment(3)
Please edit the post to clearly ask a single question; everything starting with "additionally" looks like a separate question. Also note that your link is just a link to stack overflow and not to any Playground or other IDE. – Frear
(see previous comment) I'm going to assume that the question is everything before "additionally". If so, then your issue is that TS doesn't have direct support for what I call "correlated unions" as described in ms/TS#30581. The recommended fix is refactoring as described in ms/TS#47109 as shown in this playground link. Does that fully address the question? If so I'll write up an answer explaining; if not, what am I missing? – Frear
Yes that answers my question completely. Thank you. – Honey
F
7

TypeScript doesn't have direct support for what I call "correlated unions" as described in microsoft/TypeScript#30581. The compiler can only type check a block of code like handler(message) once. If the type of handler is a union type of functions like ((message: BroadcastMessage) => void) | ((message: InitMessage) => void) and the type of message is a union of arguments like BroadcastMessage | InitMessage, the compiler can't see that as safe. After all, for arbitrary handler and message variables of those types, it could be a mistake to allow the call; if message were of type InitMessage but handler were of type (message: BroadcastMessage) => void, you'd have a problem. The only safe way to call a union of functions is with an intersection of its arguments, not a union of its arguments. A union argument could turn out to be the wrong type for the union function parameter.

In your case it is, of course, impossible for that failure to occur, because the type of handler and the type of message are correlated due to them coming from the same source. But the only way to see that would be if the compiler could analyze handler(message) once for each possible narrowing of message. But it doesn't do that. So you get that error.

If you just want to suppress the error and move on, you can use a type assertion:

handler(message as BroadcastMessage & InitMessage); // πŸ€·β€β™‚οΈ

That's technically a lie, but it is easy. But it doesn't mean the compiler sees what you're doing as type safe; if you accidentally wrote something like handler(broadcastMessageOnly as BroadcastMessage & InitMessage) (assuming broadcastMessageOnly is of type BroadcastMessage and not InitMessage) the compiler wouldn't catch the mistake. But that might not matter, as long as you're confident that you've implemented it right.


If you care about the compiler verifying type safety here, then the recommended approach to dealing with correlated unions is to refactor away from unions and toward generic indexes into simple key-value object types or mapped types over such object types. This technique is described in detail in microsoft/TypeScript#47109. For your example, the relevant changes look like this:

First let's make the basic key-value type we're going to build from:

interface MessageMap {
  broadcast: { message: number };
  init: { node_id: string; node_ids: string[] };
}

Then you can redefine your Message type to take a particular key as a type argument:

type Message<K extends keyof MessageMap = keyof MessageMap> =
  { [P in K]: BaseMessage<MessageMap[P] & { type: P }> }[K]

If you need your original message types you can recover them:

type BroadcastMessage = Message<"broadcast">;
// type BroadcastMessage = { src: string; dest: string; 
//  body: BaseMessageBody & { message: number; } & { type: "broadcast"; };
// }

type InitMessage = Message<"init">;
// type InitMessage = { src: string; dest: string; body: BaseMessageBody & 
//  { node_id: string; node_ids: string[]; } & { type: "init"; };
// }

And since Message<K> has a default type argument corresponding to the full union of keys, just Message by itself is equivalent to your original version:

type MessageTest = Message
// type MessageTest = { src: string; dest: string; 
//  body: BaseMessageBody & { message: number; } & { type: "broadcast"; };
// } | { src: string; dest: string; body: BaseMessageBody & 
//  { node_id: string; node_ids: string[]; } & { type: "init"; };
// }But 

And MessageHandler can be written in terms of MessageMap also:

type MessageHandler = {
  [K in keyof MessageMap]?: (message: Message<K>) => void;
};

Finally, you make handle a generic function that accepts a Message<K>, and the error goes away:

export const handle = <K extends keyof MessageMap>(message: Message<K>) => {

  if (message.body.type === 'init') {
    console.log('Initialized');
  }
  const handler = handlers[message.body.type];
  if (!handler) {
    console.warn('Unable to handle type', message.body.type);
    return;
  }
  handler(message); // okay
  // const handler: (message: Message<K>) => void
};

By the time you call handler, the compiler sees it as type (message: Message<K>) => void. It's no longer a union of functions; its a single function with a parameter type of Message<K>. And since message's type is also Message<K>, the call is allowed.

Is it worth refactoring to this form? If you're confident that your original version works and will continue to work, it's certainly going to be easier to just assert and move on. If you're less confident, then maybe the refactoring here is worth the effort. It depends on your use cases.

Playground link to code

Frear answered 6/5, 2023 at 15:37 Comment(10)
I dived into this and the GotHub posts this week, to learn more. First of all, thank you for your contributions, I would not have gotten far without it. A question: In the GitHub PR that introduces conditional object types (47109), we construct a RecordMap and a RecordType, where the latter has correlated properties (same type on v and f's parameter). In the answer above you do not seem to construct a new type, like RecordType, with correlated properties. Instead, you keep Message and MessageHandler separate. (1/2) – Acquisitive
Is the key here not to put the correlated properties into one combined RecordType, but instead that both the function type and the argument type links back to MessageType (RecordType in the GitHub PR)? And that the link is completed for TS by using a generic (type parameter) in the final handle function? I tried creating the Message type manually, without using a distributed object type, and setting the parameter in handle to that manually created union type, and removed the generic in handle, and it did not work. (2/2) – Acquisitive
πŸ˜“ What's a "conditional object type". What's MessageType? I think I get "GotHub" but the rest is confusing me. Maybe you mean "distributive object type" and MessageMap, in which case, yes, the important part is that everything is written in terms of the base interface (MessageMap), homomorphic mapped types over that interface, (like Message and MessageHandler), and generic indexes into those types. I don't think I'm adding anything of substance here above what I've written above, so I think I'll leave it here now. – Frear
Apologies, those were typos. I tried to do this from my phone. I meant distributive object types and MessageMap. I guess I can assume that the final generic parameter in handle is needed. – Acquisitive
Sure, as I said above, "Finally, you make handle a generic function". I'm not sure if there's something more I can do here to clear anything up. Is this in pursuit of some edit to my answer to clarify things? Or is this just a conversation we're having? If it's the latter I think it probably doesn't belong here. If it's the former, what specifically would you want to see change in this answer? – Frear
I do not 100% understand how distributive object types work, based on the answer above. Not sure if that is the intention, or if the intention is just to provide a solution to the OP. Regardless, going back and forth between github.com/microsoft/TypeScript/pull/47109 and this post has solved most of my confusion. The missing piece, both to understand why your answer above works, and to understand how to make TS see the link/correlation between two object properties using the same type, is to really get why the distributive object type and the generic type in handle is needed. – Acquisitive
cont: Is the point somehow to create a link for TS, from handle back to MessageMap, and for the link to be whole the generic type is needed on handle. I kind of expected it to work if I just used this as the type of message in handle: Message<'broadcast'> (instead of Message<'K'>) and it is not clear why that does not work. Or taking it a step further, what if I just built a BroadcastMessage type manually, instead of using a distributive object type. It still links back to MessageMap, so that does not change that link. – Acquisitive
Why what does not work? If I change Message<K> (not 'K') to Message<'broadcast'> the only thing I see fail is that the check (message.body.type === 'init') is considered in error. I think I'm going to disengage from this convo now, unless there's something you can point out that's actually wrong about my answer above. Clearing up this confusion you're having looks like it would take some actual code examples, which could possibly mean a new question post. I can't really commit to continuing this here. – Frear
I understand. For the sake of clarity, I have built a simple playground with your code above (shorturl.at/otGN4), showing that it does not in fact work when message: IM | BM and type BM = BaseMessage<MessageMap['broadcast'] & { type: 'broadcast' }>, type IM = BaseMessage<MessageMap['init'] & { type: 'init' }>. – Acquisitive
To be clear, even message: Message<'broadcast'> | Message<'init'>, or simply message: Message, will not work. message: Message<'broadcast'> alone does work, because you do not have a union in that case. A clarification on why that is the case would, in my opinion, increase the usefulness of the answer above (from great to excellent). – Acquisitive

© 2022 - 2024 β€” McMap. All rights reserved.