TypeScript中的函数联合类型奇怪地变成了参数的交集类型。

huangapple go评论65阅读模式
英文:

Typescript union type of functions weirdly becomes an intersection type of the arguments

问题

我试图以以下方式在TypeScript中对我的数据进行建模。 主要关注类型MessageHandler,它将消息中的type与接受该消息的回调进行映射。 我遵循了映射类型的手册章节

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;
};

到目前为止还不错。 MessageHandler 的扩展类型是

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

但是当我尝试像下面这样实际使用类型时:

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); // 这里出错
};

我得到以下错误。 不知何故,处理程序类型已转换为const handler: (message: BroadcastMessage & InitMessage) => void

error: TS2345 [ERROR]: Argument of type 'Message' is not assignable to parameter of type 'BroadcastMessage & InitMessage'.
  Type 'Message' is not assignable to type 'BroadcastMessage & InitMessage'.
    Type 'Message' 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);
                ~~~~~~~

与此相关的一些问题在堆栈溢出上有一些,但我无法通过遵循它们来解决我的问题。 在这里 是带有所有代码的播放器。

英文:

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&lt;TBody&gt; = {
	src: string;
	dest: string;
	body: BaseMessageBody &amp; TBody;
};

type BroadcastMessage = BaseMessage&lt;{
	type: &#39;broadcast&#39;;
	message: number;
}&gt;;

type InitMessage = BaseMessage&lt;{
	type: &#39;init&#39;;
	node_id: string;
	node_ids: string[];
}&gt;;

type Message = BroadcastMessage | InitMessage;

type Body = Message[&#39;body&#39;];

type MessageHandler = {
	[M in Message as M[&#39;body&#39;][&#39;type&#39;]]?: (message: M) =&gt; void;
};

So far so good. The expanded type of MessageHandler is

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

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

const handlers: MessageHandler = {};

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

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

error: TS2345 [ERROR]: Argument of type &#39;Message&#39; is not assignable to parameter of type &#39;BroadcastMessage &amp; InitMessage&#39;.
  Type &#39;BroadcastMessage&#39; is not assignable to type &#39;BroadcastMessage &amp; InitMessage&#39;.
    Type &#39;BroadcastMessage&#39; is not assignable to type &#39;InitMessage&#39;.
      Types of property &#39;body&#39; are incompatible.
        Type &#39;BaseMessageBody &amp; { type: &quot;broadcast&quot;; message: number; }&#39; is not assignable to type &#39;BaseMessageBody &amp; { type: &quot;init&quot;; node_id: string; node_ids: string[]; }&#39;.
          Type &#39;BaseMessageBody &amp; { type: &quot;broadcast&quot;; message: number; }&#39; is missing the following properties from type &#39;{ type: &quot;init&quot;; node_id: string; node_ids: string[]; }&#39;: 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.

答案1

得分: 2

TypeScript不直接支持我所谓的“相关联合”("correlated unions"),如microsoft/TypeScript#30581中所描述的。编译器只能对像handler(message)这样的代码块进行一次类型检查。如果handler的类型是像((message: BroadcastMessage) => void) | ((message: InitMessage) => void)这样的联合类型并且message的类型是像BroadcastMessage | InitMessage这样的参数的联合类型,编译器无法将其视为安全。毕竟,对于任意的handlermessage变量,允许调用可能是个错误;如果message的类型是InitMessagehandler的类型是(message: BroadcastMessage) => void,就会出现问题。调用联合类型的函数的唯一安全方式是使用其参数的交集,而不是其参数的联合类型。联合参数可能会变成联合函数参数的错误类型。

在您的情况下,当然,这种失败发生的可能性是不可能的,因为handler的类型和message的类型是相关联的,因为它们来自同一源。但要看到这一点,编译器必须为message的每种可能的缩小分析一次handler(message)。但它不会这样做。所以你会得到那个错误。

如果您只想消除错误并继续,可以使用类型断言

handler(message as BroadcastMessage & InitMessage); // 🤷‍♂️

那在技术上是个谎言,但它很容易。但这并不意味着编译器认为您所做的是类型安全的;如果您不小心写了类似handler(broadcastMessageOnly as BroadcastMessage & InitMessage)(假设broadcastMessageOnly的类型不是InitMessage而是BroadcastMessage),编译器将无法捕获错误。但只要您确信已经正确实现了它,这可能无关紧要。

如果您关心编译器在这里验证类型安全性,那么处理相关联合的建议方法是重构,远离联合类型,转向泛型、简单的键值对象类型或映射类型。这种技术在microsoft/TypeScript#47109中有详细描述。对于您的示例,相关更改如下所示:

首先,让我们创建要构建的基本键值类型:

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

然后,您可以重新定义您的Message类型以将特定键作为类型参数传递:

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

如果您需要恢复原始的消息类型,可以这样做:

type BroadcastMessage = Message<"broadcast">;

type InitMessage = Message<"init">;

由于Message<K>具有默认的类型参数,对应于键的完整联合,因此仅Message本身等效于您的原始版本:

type MessageTest = Message;

并且MessageHandler也可以基于MessageMap编写:

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

最后,您可以将handle编写为接受Message<K>的泛型函数,错误就会消失:

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
};

在调用handler之时,编译器将其视为类型(message: Message<K>) => void。它不再是函数的联合;它是一个参数类型为Message<K>的单一函数。由于message的类型也是Message<K>,因此调用是允许的。

是否值得重构成这种形式?如果您对原始版本的工作方式有信心,并且相信它将继续正常工作,那么肯定更容易只是断言并继续。如果您不太确定,那么也许这里的重构值得一试。这取决于您的用例。

英文:

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) =&gt; void) | ((message: InitMessage) =&gt; 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) =&gt; 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 &amp; InitMessage); // &#129335;‍♂️

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 &amp; 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&lt;K extends keyof MessageMap = keyof MessageMap&gt; =
  { [P in K]: BaseMessage&lt;MessageMap[P] &amp; { type: P }&gt; }[K]

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

type BroadcastMessage = Message&lt;&quot;broadcast&quot;&gt;;
// type BroadcastMessage = { src: string; dest: string; 
//  body: BaseMessageBody &amp; { message: number; } &amp; { type: &quot;broadcast&quot;; };
// }

type InitMessage = Message&lt;&quot;init&quot;&gt;;
// type InitMessage = { src: string; dest: string; body: BaseMessageBody &amp; 
//  { node_id: string; node_ids: string[]; } &amp; { type: &quot;init&quot;; };
// }

And since Message&lt;K&gt; 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 &amp; { message: number; } &amp; { type: &quot;broadcast&quot;; };
// } | { src: string; dest: string; body: BaseMessageBody &amp; 
//  { node_id: string; node_ids: string[]; } &amp; { type: &quot;init&quot;; };
// }But 

And MessageHandler can be written in terms of MessageMap also:

type MessageHandler = {
  [K in keyof MessageMap]?: (message: Message&lt;K&gt;) =&gt; void;
};

Finally, you make handle a generic function that accepts a Message&lt;K&gt;, and the error goes away:

export const handle = &lt;K extends keyof MessageMap&gt;(message: Message&lt;K&gt;) =&gt; {

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

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

huangapple
  • 本文由 发表于 2023年5月6日 18:12:32
  • 转载请务必保留本文链接:https://go.coder-hub.com/76188330.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定