前几个月我们重构了消息通知机制,重构前每新增一种消息都需要新增以下代码:
- 一个服务器端消息类,如
ServerTaskAssignedMessage
,提供若干个方法,比如buildEmailOptions
、buildPushOptions
。 - 一个客户端消息类,如
ClientTaskAssignedMessage
,提供一个方法buildNotificationOptions
。 - 补充多处类型声明,并加入消息的联合类型。
如此一来,新增一种消息就要增加上百行分散在多个文件的代码,开发体验非常糟糕。加上需要解决多路消息 fallback 和合并等问题,我们决定对消息通知机制进行重构,抽离出了一个叫 Mr. Universe(《冲出宁静号》中的角色)的架子。由于没有文档加上需要适配较多接口,我就不放链接了。
重构后,我们大约是这样定义消息的:
export const taskMessageTemplateDefinitionDict = {
'task:assigned': {
brief({task, assignee}: {task: Task; assignee: User}) {
return `...`;
},
details({
task,
assignee,
user,
}: {
task: Task;
assignee: User;
user: GeneralUser;
}) {
return `...`;
},
},
'task:assigned-to-me': {
brief({task}: {task: Task}) {
return `...`;
},
details({task, user}: {task: Task; user: GeneralUser}) {
return `...`;
},
}
};
使用时则大约是这样:
channel.send({
type: 'task:assigned',
targets,
data: {
task,
assignee,
user,
},
});
我们需要让 TypeScript 类型系统完成的工作之一就是,将上方的 taskMessageTemplateDefinitionDict
转换为下方 channel.send(params)
方法中 params
的类型。这个过程中需要注意不同消息有不同的参数。
到这里先解释下这个消息机制中的几个概念,方便理解以上示例内容之间的关联。我们将一条具体到某个发送方式的消息的构建分为三层。
- 第一层是数据(data),也就是
channel.send(params)
中的data
属性,以及taskMessageTemplateDefinitionDict
中某一消息下的某一个模板函数的参数中的具体键值。在data
中传入的task
,assignee
,user
我们都可以在上方'task:assigned'
消息的一个或多个模板函数中找到对应的值。 - 第二层是模板数据(template data),也就是由某一个消息下的模板函数生成的数据对象(键值和模板函数名/模板函数返回值一一对应)。所有消息通常都需要生成结构大致相同的模板数据。
- 第三层是信号(signal),这个是借用 Mr. Universe 中的“Can't stop the signal”的桥段。不同的消息发送渠道对应不同的信号,这些信号在收到模板数据后,会从中取出自己关心的字段,组合成最终要发送的消息。
相较于重构前的方案,新方案构建消息的灵活度稍低,但完全满足实际使用需求。
如此一来我们需要在类型上做的处理就比较明了了。由于同一消息有多个模板函数,每个模板函数的参数可能各不相同,调用时传入的是一个 data
对象,这个 data
对象需要包含所有模板函数参数的字段。于是我们需要:
- 取出模板函数的第一个参数类型。
- 对取出的参数类型求交。
首先是取出第一个参数类型:
type MessageTemplateFunction = (data: unknown) => unknown;
type __MessageTemplateFunctionSourceData<
TMessageTemplateFunction
> = TMessageTemplateFunction extends MessageTemplateFunction
? Parameters<TMessageTemplateFunction>[0]
: never;
这里使用了 TypeScript 内建的 Parameters
类型,这个类型在函数参数位置使用了 infer
来获得参数类型:
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
接着是取出消息下的所有模板函数的第一个参数类型并求交:
type Intersection<TUnion> =
(TUnion extends any ? (_: TUnion) => void : never) extends (_: infer T) => void
? T
: never;
interface MessageTemplateDefinition {
[TKey: string]: MessageTemplateFunction;
}
type __ChannelSendParamsData<
TMessageTemplateDefinition
> = TMessageTemplateDefinition extends MessageTemplateDefinition
? Intersection<
__MessageTemplateFunctionSourceData<
TMessageTemplateDefinition[keyof TMessageTemplateDefinition]
>
>
: {};
这里引入了一个相对晦涩的工具类型 Intersection
,它利用了函数参数类型兼容性判断时的性质将联合类型转变为交叉类型。我们来做一下阅读理解,先看其中的一部分:
TUnion extends any ? (_: TUnion) => void : never
这一部分我们获得了一个函数类型的联合类型,比如 TUnion
为 Foo | Bar
,那这里得到的类型便是 ((_: Foo) => void) | ((_: Bar) => void)
,记作 TUnionFunction
。
此时如果 TUnionFunction extends (_: T) => void
成立,显然 T
的类型应该满足 Foo & Bar
,于是再借助 infer
我们就可以将 Foo | Bar
成功转换为 Foo & Bar
。
那么在此基础上,加上映射类型,我们就可以把以上定义转换为消息参数的联合类型了:
interface MessageTemplateDefinitionDict {
[TKey: string]: MessageTemplateDefinition;
}
type __ChannelSendParamsAndDataSection<
TDefinitionDict extends MessageTemplateDefinitionDict,
TType extends keyof TDefinitionDict
> = TType extends string
? {
type: TType;
data: __ChannelSendParamsData<TDefinitionDict[TType]>;
}
: never;
type ChannelSendParams<
TTarget,
TDefinitionDict extends MessageTemplateDefinitionDict
> = {
targets: TTarget[];
} & __ChannelSendParamsAndDataSection<
TDefinitionDict,
keyof TDefinitionDict
>;
以上代码可以在 CodeSandbox 中测试。
至此,我们实现了从模板函数类型到消息参数类型的转换,同样我们也可以从模板函数类型中提取最终的模板数据类型,限于篇幅这篇文章就不再赘述。不过在实际的实现里,还需要处理很多细节,如:
- 模板函数参数列表可能为空。
- 增加占位符机制,使部分参数可选。
- 校验 definition dict 类型。
由于类型运算相对抽象,可读性差,在类型编写中需要更加细心和耐心。但以 Mr. Universe 为例,经过重构后,新增消息种类只需要在 definition dict 中添加相关模板函数并在函数参数中声明数据类型,类型安全就得到了保证,开发者使用体验大幅提升。对于这种场景,在类型上投入精力是非常值得的。
祝大家举一反三。