本节目标:学会用判别联合(discriminated union)为一组持续流动的 WebSocket 消息建模。你会知道为什么
any在长连接上比在 HTTP 上更危险、如何选出稳定的判别键、如何用never让漏处理的分支在编译期暴露,以及前后端如何共享同一份协议定义而不重复维护。
10.1 WebSocket 消息协议判别联合
上一章的任务由时钟或用户行为触发,每一次都是独立的一次调用。从这一节开始,通信模型变了:客户端与服务端之间建立一条长期存活的 TCP 通道,两端在这条通道上互相推送消息。请求-响应那种「一问一答、边界清晰」的假设不再成立。
10.1.1 为什么长连接让类型安全更脆弱
HTTP 场景里,每个 endpoint 的入参与出参是分开建模的,类型检查器能帮你把住边界:
// HTTP:一次调用一个明确的形状
async function createOrder(body: CreateOrderDto): Promise<OrderDto> {
return http.post('/orders', body);
}
WebSocket 只有一个出口——socket.send()——和一个入口——socket.onmessage。几十种语义完全不同的消息挤在同一条通道上:
socket.onmessage = (event) => {
const msg = JSON.parse(event.data); // msg: any
if (msg.type === 'order.created') {
console.log(msg.orderId); // 拼错成 msg.orderID 也能过编译
}
};
JSON.parse 返回 any,这意味着类型检查在最需要它的地方失效了。HTTP 里至少还有一层路由能区分形状,长连接里连这个都没有,全靠开发者自己按 type 字段判断。判别联合要做的,就是把这种「手工判断」变成编译器能验证的结构。
10.1.2 用判别键把消息族变成联合类型
判别联合(也叫可辨识联合、tagged union)的核心是:每一个成员类型都带一个字面量类型的共同字段,用它的取值来区分分支。
type OrderStatus = 'pending' | 'paid' | 'cancelled';
// 服务端 → 客户端
type ServerMessage =
| { type: 'order.created'; payload: { orderId: string; amount: number } }
| { type: 'order.updated'; payload: { orderId: string; status: OrderStatus } }
| { type: 'user.joined'; payload: { userId: string; nickname: string } }
| { type: 'error'; payload: { code: number; message: string } };
// 客户端 → 服务端
type ClientMessage =
| { type: 'subscribe'; payload: { channel: string } }
| { type: 'unsubscribe'; payload: { channel: string } }
| { type: 'ping'; payload: { ts: number } };
三个设计决策值得展开。
一、判别键用 type 还是 kind。 两者都行,但要在整个项目里统一。type 与 TypeScript 关键字同名,作为属性名完全合法,不必刻意回避。
二、判别键的取值必须是字符串字面量,不能是 string。 一旦被拓宽,联合就塌缩成两个几乎相同的成员,判别能力瞬间消失:
// 反例:判别键被拓宽成 string,收窄失效
type Bad =
| { type: string; payload: { a: number } }
| { type: string; payload: { b: string } };
修正的办法是让判别键的取值来源本身就是字面量。从常量表派生时务必加 as const:
const TYPES = ['order.created', 'order.updated', 'user.joined', 'error'] as const;
type MessageType = (typeof TYPES)[number]; // 四个字面量的联合,而非 string
三、把判别键放在顶层,不要嵌套。 嵌在 payload.type 里的联合依然能工作,但写法更啰嗦,switch 收窄也要多写一层判断。顶层扁平是社区惯例。
10.1.3 收窄与穷尽检查
判别联合最大的收益是 switch 里的自动收窄:
function describe(msg: ServerMessage): string {
switch (msg.type) {
case 'order.created':
return `订单 ${msg.payload.orderId} 创建,金额 ${msg.payload.amount}`;
case 'order.updated':
return `订单 ${msg.payload.orderId} 状态变为 ${msg.payload.status}`;
case 'user.joined':
return `${msg.payload.nickname} 加入`;
case 'error':
return `错误 ${msg.payload.code}: ${msg.payload.message}`;
}
}
在 case 'order.created' 分支里,msg 被自动收窄为联合中的第一个成员,msg.payload.amount 合法;而在 case 'user.joined' 分支里访问 msg.payload.amount 会直接报错:
// 在 'user.joined' 分支里写 msg.payload.amount,编译器给出:
// Property 'amount' does not exist on type '{ userId: string; nickname: string; }'.
穷尽检查靠 never。在 switch 末尾加一个 default 分支,把剩余值赋给 never:
function assertNever(x: never): never {
throw new Error(`unhandled message: ${JSON.stringify(x)}`);
}
function handle(msg: ServerMessage): void {
switch (msg.type) {
case 'order.created': return handleCreated(msg.payload);
case 'order.updated': return handleUpdated(msg.payload);
case 'user.joined': return handleJoined(msg.payload);
case 'error': return handleError(msg.payload);
default: return assertNever(msg);
}
}
这段代码的价值在于未来:当同事往 ServerMessage 里新增 { type: 'order.cancelled'; ... } 时,default 分支里的 msg 就不再是 never,编译立刻失败并精确指出位置。若不加 default,新消息会被静默忽略——这是长连接里最难排查的一类 bug,因为「什么都没发生」本身不会报错。更完整的错误建模见 《TypeScript编程实战》3.1 Result/Either 与类型化错误
。
10.1.4 JSON 边界上的运行时校验
判别联合解决的是编译期问题,但 JSON.parse 的输入来自网络,随时可能是错误或恶意数据。as ServerMessage 只是让编译器闭嘴,运行时该崩还是会崩:
// 危险:断言不做任何检查
const msg = JSON.parse(raw) as ServerMessage;
if (msg.type === 'order.created') {
const n: number = msg.payload.amount; // amount 可能是 undefined
}
正确做法是在边界上做一次真实校验。Zod 能同时给出运行时校验与静态类型:
import { z } from 'zod';
const OrderCreated = z.object({
type: z.literal('order.created'),
payload: z.object({ orderId: z.string(), amount: z.number().positive() }),
});
const ErrorMsg = z.object({
type: z.literal('error'),
payload: z.object({ code: z.number().int(), message: z.string() }),
});
// discriminatedUnion 用 type 字段自动分发,比链式 or() 更快也更准
const ServerMessageSchema = z.discriminatedUnion('type', [OrderCreated, ErrorMsg]);
type ServerMessage = z.infer<typeof ServerMessageSchema>;
function parseServerMessage(raw: string): ServerMessage {
const json: unknown = JSON.parse(raw);
return ServerMessageSchema.parse(json); // 失败抛 ZodError
}
关键点是 z.infer:类型从 schema 反推,类型与校验规则只有一个来源。手写 interface 再手写校验函数,两边一定会漂移。表单场景的同类做法见 《TypeScript编程实战》13.1 React Hook Form + Zod
。
校验失败时不要静默丢弃,也不要让异常逃出 onmessage:
socket.onmessage = (event) => {
const parsed = ServerMessageSchema.safeParse(JSON.parse(event.data));
if (!parsed.success) {
logger.warn({ issues: parsed.error.issues }, 'malformed server message');
return; // 记日志后忽略,不让一条坏消息打断整条连接
}
handle(parsed.data);
};
10.1.5 前后端共享协议包
协议定义必须只有一份。monorepo 里通常建一个 packages/protocol 包,前后端都依赖它:
{
"name": "@acme/protocol",
"version": "1.4.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
}
}
// packages/protocol/src/index.ts
export const ServerMessageSchema = z.discriminatedUnion('type', [
OrderCreated,
OrderUpdated,
UserJoined,
ErrorMsg,
]);
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
export const PROTOCOL_VERSION = '1.4.0' as const;
共享包最大的好处是让重命名重构变成原子操作:改掉 orderId 后,前端那个还在读 msg.payload.orderID 的文件会立刻编译失败。若两端各自维护一份 interface,这类字段漂移只有到线上才会暴露。路径别名与工作区配置见 《TypeScript编程实战》2.1 路径别名与 monorepo 结构
。
10.1.6 协议版本演进
长连接比 HTTP 更难做版本兼容:HTTP 每次请求都是新的,可以按 URL 走 /v2/;而一条 WebSocket 连接可能存活数小时,服务端滚动发布时新旧两个版本会同时在线。
| 变更类型 | 是否兼容 | 处理方式 |
|---|---|---|
| 新增可选字段 | 兼容 | 直接加,旧客户端忽略即可 |
| 新增消息类型 | 兼容 | 旧客户端靠 default 忽略并记日志,不要抛错断连 |
| 重命名字段 | 不兼容 | 加新字段、双写一段时间、再删旧字段 |
| 修改字段类型 | 不兼容 | 同上,或用新消息类型承载新语义 |
在握手阶段协商版本是最稳的做法:
type ClientMessage =
| { type: 'hello'; payload: { protocolVersion: string; clientId: string } }
| { type: 'subscribe'; payload: { channel: string } };
function negotiate(clientVersion: string): void {
const [major] = clientVersion.split('.');
if (major !== PROTOCOL_VERSION.split('.')[0]) {
throw new ProtocolMismatchError(clientVersion, PROTOCOL_VERSION);
}
}
只比较主版本号:次版本只增不减、补丁版本不影响兼容,这是语义化版本在协议上的自然应用。完整的契约演进策略见 《TypeScript编程实战》16.3 契约版本演进与兼容 。
10.1.7 五个常见坑
一、判别键被推断成 string。 从配置或对象字面量里读出来的 type 常被拓宽,导致收窄失效。用 as const 或显式标注联合类型固定住。
二、用 any 接 JSON.parse。 这等于放弃了整条链路的类型检查。至少要用 unknown 起步,再走一次校验。
三、default 分支直接 throw。 新增消息类型时旧客户端会因此断连。旧客户端应当忽略未知类型并记日志,而不是断开连接。
四、只加了类型没加 schema。 类型是编译期的,运行时不校验,两端不一致时依然崩溃。
五、把「事件名」当判别键却允许动态拼接。 type: \user.${action}`` 这种模板字面量类型看着聪明,实际会制造无穷多分支,收窄与穷尽检查双双失效。判别键的取值集合必须有限且显式。
六、只在服务端做了校验。 客户端同样会收到伪造或过期的消息,双向都要过一遍 schema。服务端校验保护服务端,客户端校验保护客户端,两者不能互相替代。
10.1.8 与本书其它章节的衔接
消息边界上的校验复用了表单那一套 schema 思路,见 《TypeScript编程实战》13.1 React Hook Form + Zod ;判别联合同样用于队列 payload 建模,见 《TypeScript编程实战》9.1 BullMQ 队列模型与 payload 泛型 ;校验失败与断连都要走结构化日志,见 《TypeScript编程实战》3.3 结构化日志与脱敏 。
站内延伸阅读:WebSocket 实时通信架构 、WebSocket 与 SSE 实时架构对比 、Node.js WebSocket 实时通信 、消息协议版本化设计 、TypeScript 类型化事件流 。
小结
本节的核心是把「一条通道上流动的一组消息」建模成一个判别联合。判别键必须是字面量类型、取值集合有限、写在顶层;收窄让 switch 的每个分支都拿到精确类型;never 穷尽检查保证未来新增消息时编译期就能发现漏处理的分支。类型只解决编译期问题,JSON.parse 之后的运行时校验必须由 schema 承担,且类型要从 schema 反推,避免两份定义漂移。
共享协议包让重命名成为原子操作,版本协商则要在握手阶段就完成——长连接的生命周期比 HTTP 请求长得多,滚动发布时新旧版本必然共存,主版本号不匹配就要明确拒绝。
下一节换一个方向:当服务端只需要单向推送、且客户端要的是「像打字机一样逐字出现」的体验时,WebSocket 是过重的选择。SSE 会用一条普通的 HTTP 长响应完成同样的推送,代价是单向,收益是穿透代理更简单、断线重连由浏览器代劳。
阅读导航:上一节:9.3 定时任务与并发限流 · 下一节:10.2 SSE 与流式响应 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。