英文:
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<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.
答案1
得分: 2
TypeScript不直接支持我所谓的“相关联合”("correlated unions"),如microsoft/TypeScript#30581中所描述的。编译器只能对像handler(message)
这样的代码块进行一次类型检查。如果handler
的类型是像((message: BroadcastMessage) => void) | ((message: InitMessage) => void)
这样的联合类型并且message
的类型是像BroadcastMessage | InitMessage
这样的参数的联合类型,编译器无法将其视为安全。毕竟,对于任意的handler
和message
变量,允许调用可能是个错误;如果message
的类型是InitMessage
但handler
的类型是(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) => 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.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论