跳到主要内容

ts-pattern:把分支判断写得更稳

如果你写 TypeScript 写到一定阶段,迟早会碰到一种代码:

  • if / else if / else 一路往下堆
  • switch 分了很多支,但漏掉一个状态也没人提醒
  • 联合类型明明已经定义得很清楚,到了真正分支处理时还是写得很散

这时候,ts-pattern 往往会让人眼前一亮。

它不是运行时校验库,不是状态管理库,也不是 React 专用工具。它做的事很单纯:把复杂条件分支收成一个表达式,同时把 TypeScript 的联合类型推断吃得更干净一点。

从这个站点现有结构来看,它更适合放在 TypeScript 这一组,而不是 运行时校验 或某个具体框架下面。因为它的核心价值不在“校验输入”,而在“怎么处理联合类型和控制流”。

先说结论:它适合放在哪

更合适的落点是:

docs/foundations/typescript/ts-pattern.md

原因很直接:

  • 它本质上是 TypeScript 控制流和联合类型处理工具
  • 它和 高级类型项目接入项目案例 是同一条学习路径
  • 它不是框架绑定能力,React、Node、前端状态机里都能用
  • 它也不属于运行时边界,不该和 zod / arktype 那组混在一起

如果以后这个站点想扩一组“TypeScript 工具生态”,ts-pattern 也能单独长成一个小分支。但在现在这版目录下,先放进 foundations/typescript 最自然。

ts-pattern 到底解决什么问题

官方把它定义成 TypeScript 的模式匹配库,重点有两件事:

  • 把复杂条件写成更紧凑的分支表达式
  • 通过 .exhaustive() 做穷尽检查,减少漏分支

这两个点听起来不新鲜,但落到真实代码里很有用。

比如我们平时常写这种联合类型:

type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };

switch 当然能写:

function renderState(state: RequestState) {
switch (state.status) {
case "idle":
return "还没开始";
case "loading":
return "加载中";
case "success":
return state.data.join(", ");
case "error":
return state.message;
}
}

这个版本不算差,但有两个常见问题:

  • 分支一复杂,嵌套判断会继续长
  • 如果后面给 RequestState 新增一种状态,很多时候要靠人肉记得回来补

换成 ts-pattern 会像这样:

import { match } from "ts-pattern";

function renderState(state: RequestState) {
return match(state)
.with({ status: "idle" }, () => "还没开始")
.with({ status: "loading" }, () => "加载中")
.with({ status: "success" }, ({ data }) => data.join(", "))
.with({ status: "error" }, ({ message }) => message)
.exhaustive();
}

这里最值钱的不是“看起来高级”,而是:

  • 分支条件和返回值被收在一个连续结构里
  • 每个分支里拿到的是已经缩窄过的类型
  • .exhaustive() 会逼你把所有可能情况都交代完

它最适合哪些场景

不是所有地方都该上 ts-pattern。它最适合下面这几类问题。

1. 联合类型很多,分支要写得清楚

比如:

  • 接口请求状态
  • reducer action
  • 表单提交流程状态
  • 消息事件类型
  • WebSocket 推送类型

只要你的数据结构本身就是“几种明确分支之一”,它通常就很顺手。

2. 嵌套对象判断开始变多

如果分支不只是判断 state.status,还要继续看内部字段,ts-pattern 比一层套一层 if 更容易读。

type Result =
| { type: "ok"; data: { kind: "text"; content: string } }
| { type: "ok"; data: { kind: "image"; src: string } }
| { type: "error"; error: Error };

const output = match(result)
.with({ type: "error" }, () => "请求失败")
.with({ type: "ok", data: { kind: "text" } }, ({ data }) => data.content)
.with({ type: "ok", data: { kind: "image" } }, ({ data }) => data.src)
.exhaustive();

这种写法的好处是:结构一眼就能对上数据形状。

3. 你想强制自己别漏分支

这其实是很多人真正开始喜欢它的原因。

项目早期,switch 漏一个 case 看起来问题不大。项目一大,状态多了、协作者多了,“少补了一支”的问题会越来越常见。

.exhaustive() 的价值就在这里。它把“记得补全”从习惯问题,尽量变成类型系统问题。

核心 API,先掌握这几个就够了

官方能力很多,但多数项目先会下面这些就够用了。

match

入口就是 match(value)

你可以把它理解成:开始针对这个值做一组分支匹配。

import { match } from "ts-pattern";

const label = match(status)
.with("idle", () => "未开始")
.with("loading", () => "加载中")
.with("success", () => "成功")
.with("error", () => "失败")
.exhaustive();

如果只是简单字面量分支,这已经足够替代不少 switch

.with

.with 用来声明“如果匹配这个模式,就执行这个分支”。

模式可以是:

  • 字面量
  • 对象结构
  • 数组或元组
  • 通配符
  • 谓词条件

最常见的还是对象结构匹配:

const text = match(event)
.with({ type: "click" }, () => "点击")
.with({ type: "submit", payload: { valid: true } }, () => "提交成功")
.with({ type: "submit", payload: { valid: false } }, () => "提交失败")
.exhaustive();

.exhaustive()

这是最该记住的一个。

它的作用不是“结束链式调用”这么简单,而是要求这组分支必须穷尽。官方 README 也把它列成核心特性之一。

如果你新增了联合类型分支,但这里没处理完,类型检查通常就会把问题拦下来。

这也是为什么我更愿意把 ts-pattern 归进 TypeScript 体系,而不是“语法糖工具”。

.otherwise()

如果你不想强制穷尽,而是愿意留一个兜底分支,可以用 .otherwise()

const label = match(input)
.with("a", () => "A")
.with("b", () => "B")
.otherwise(() => "unknown");

这适合开放输入,不适合本来就应该封闭的联合类型。

换句话说:

  • 封闭状态集,优先 .exhaustive()
  • 开放输入源,才考虑 .otherwise()

P.when

有些场景不是单纯看字面量,而是要带条件判断,这时可以用 P.when

import { match, P } from "ts-pattern";

const level = match(score)
.with(P.when((n) => n >= 90), () => "A")
.with(P.when((n) => n >= 75), () => "B")
.otherwise(() => "C");

这类写法比连续的 if 更集中,但也别滥用。条件一复杂,普通函数拆出去通常还是更清楚。

P.select

如果你只想把匹配到的某个字段拿出来,可以用 P.select

import { match, P } from "ts-pattern";

const imageSrc = match(result)
.with({ type: "ok", data: { kind: "image", src: P.select() } }, (src) => src)
.otherwise(() => "");

这在嵌套对象里会比手动一路解构更顺。

switchif/else 比,差别到底在哪

很多人第一次看会问:这不就是换了个写法吗?

不完全是。

if / else

优点:

  • 最直接
  • 谁都会写
  • 简单条件时成本最低

问题:

  • 复杂后容易散
  • 类型缩窄路径不总是整齐
  • 很难做“结构匹配”

switch

优点:

  • 对单个判别字段很清楚
  • 原生语法,没有额外依赖

问题:

  • 更适合单层字面量分支
  • 一旦还要继续判断内部结构,就容易重新回到 if
  • 默认没有 ts-pattern 这种结构匹配表达力

ts-pattern

优点:

  • 更擅长联合类型和嵌套结构
  • 分支定义集中
  • 穷尽检查更自然

代价:

  • 多一层库心智
  • 团队没见过时,第一次读会慢一点
  • 乱用会把简单逻辑写得比原来还绕

所以它不是“全面替代 switch”,而是更适合处理中高复杂度分支。

在 React 里哪里最容易见效

如果这是前端项目,我会优先看三个位置。

reducer

type Action =
| { type: "setKeyword"; payload: string }
| { type: "submit" }
| { type: "success"; payload: string[] }
| { type: "error"; payload: string };

function reducer(state: State, action: Action): State {
return match(action)
.with({ type: "setKeyword" }, ({ payload }) => ({
...state,
keyword: payload,
}))
.with({ type: "submit" }, () => ({
...state,
loading: true,
error: null,
}))
.with({ type: "success" }, ({ payload }) => ({
...state,
loading: false,
data: payload,
}))
.with({ type: "error" }, ({ payload }) => ({
...state,
loading: false,
error: payload,
}))
.exhaustive();
}

action 越多,这种写法的收益越明显。

请求状态渲染

页面里最常见的 loading / success / error / empty,也很适合集中写。

事件消息分发

像埋点事件、iframe 消息、worker 消息、WebSocket 消息,本质上都是“看 type 决定处理逻辑”,这类地方也很适合。

什么时候别上 ts-pattern

这比“什么时候该用”还重要。

只有一两个简单条件

如果只是:

if (!user) return null;
if (user.disabled) return "disabled";

那就别为了“统一风格”硬改成模式匹配。

简单逻辑保持简单,永远比“全站都用一个库”更重要。

输入根本不是封闭集合

如果你处理的是任意对象、松散接口返回、第三方脏数据,先想的是校验和清洗,而不是直接做模式匹配。

这也是它和 zod 那组库的边界:

  • zod / arktype 更关注“这个输入合法不合法”
  • ts-pattern 更关注“这个已经进入系统的值该走哪条分支”

前者管边界,后者管分发。

团队还没建立联合类型建模习惯

如果项目里大量还是这种数据:

type State = {
status?: string;
data?: unknown;
error?: string | null;
};

ts-pattern 的收益会打折。

它最好吃的是“判别联合”这类结构清晰的数据模型。模型本身松散,分支工具再漂亮也救不了太多。

一条很实用的判断标准

我自己的经验是:

  • 如果 switch 已经写得很顺,继续用 switch
  • 如果你开始出现大量联合类型分支、嵌套判断、状态分发,值得上 ts-pattern
  • 如果你上了之后每个地方都只是写两条 .with(),那多半是用过头了

它最好的位置不是“全项目到处刷存在感”,而是专门收拾那些原本已经开始变乱的分支代码。

推荐的学习顺序

如果你已经看过这组里的 高级类型,那读 ts-pattern 会很顺,因为它和下面这些能力关系很近:

  • 联合类型
  • 类型守卫
  • 判别字段缩窄
  • never 带来的穷尽检查思路

更具体一点说,ts-pattern 不是替代 TypeScript 基础能力,而是把这些能力组织得更像一个可复用工具。

所以更自然的阅读路径是:

  1. 先理解联合类型和类型缩窄
  2. 再看 ts-pattern 怎么把分支判断收得更稳
  3. 最后回到项目代码里,只挑那些真的复杂的分支改

小结

ts-pattern 值得写进这个站点,而且值得放在 TypeScript 模块下。

不是因为它“新”或者“酷”,而是因为它正好补上了很多 TypeScript 文档常常没讲透的一块:类型系统定义完之后,复杂分支到底怎么写得既清楚又不容易漏。

如果你现在写的项目里已经出现这些信号:

  • 状态联合越来越多
  • reducer 越来越长
  • switchif 嵌套开始发散
  • 每次加新状态都怕漏改

ts-pattern 很值得认真看一遍。

参考资料