原文:🔗 Bringing Pattern Matching to TypeScript 🎨 Introducing TS-Pattern
译者:legend80s@
JavaScript与编程艺术
🤝 AI一个很惊艳的库,跃跃欲试了,大家不妨使用和反馈下。
typescript
import { match, P } from 'ts-pattern';
type Data =
| { type: 'text'; content: string }
| { type: 'img'; src: string };
type Result =
| { type: 'ok'; data: Data }
| { type: 'error'; error: Error };
const result: Result = ...;
const html = match(result)
.with({ type: 'error' }, () => <p>Oups! An error occured</p>)
.with({ type: 'ok', data: { type: 'text' } }, (res) => <p>{res.data.content}</p>)
.with({ type: 'ok', data: { type: 'img', src: P.select() } }, (src) => <img src={src} />)
.exhaustive();
在过去的几年里,前端开发变得越来越声明式 。React改变了我们从命令式 操作DOM到声明式表达给定状态下DOM应该是什么样的思维方式。它已经被行业广泛采用,现在我们意识到声明式代码更容易理解,拥抱这种范式可以避免很多错误,因此我们再也回不去了。
这不仅仅是用户界面------状态管理库也在转向声明式。像XState、Redux等许多库让你能够声明式地管理应用状态,从而解锁同样的好处:编写更容易理解 、修改 和测试的代码。如今,我们真正生活在一个声明式编程的世界里!
然而,JavaScript和TypeScript并不是为这种范式设计的,这些语言缺少了一个非常重要的拼图:声明式代码分支。
声明式编程本质上是定义表达式 而不是语句 ------也就是说,计算出一个值的代码。核心思想是将描述需要做什么 的代码与解释这个描述以产生副作用的代码分开。例如,创建一个React应用本质上是使用JSX描述DOM应该是什么样的,然后让React在幕后高效地改变DOM。
🤷♀️ if
、else
和switch
的问题
如果你使用过React,你可能注意到在JSX中代码分支并不直观。我们习惯使用的if
、else
或switch
语句的唯一使用方式是放在自调用函数(也称为 立即调用函数表达式 ,简称 IIFE)中:
typescript
declare let fetchState:
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error" };
<div>
{
(() => {
switch (fetchState.status) {
case "loading":
return <p>Loading...</p>;
case "success":
return <p>{fetchState.data}</p>;
case "error":
return <p>Oops, an error occured</p>;
}
})() // 立即调用该函数
}
</div>;
这需要很多样板代码,看起来也不太美观。我们不能责怪React------只是因为命令式的语句 (它们不返回任何值)并不适合声明式上下文。我们需要的是表达式。
❓ 三元运算符还不够
三元运算符是一种简洁的方式,可以根据布尔值返回两个不同的值:
typescript
bool ? valueIfTrue : valueIfFalse;
由于三元运算符是表达式,它们成为了在React中编写代码分支的 事实上的 方式。如今,我们的组件大多看起来像这样:
typescript
const SomeComponent = ({ fetchState }: Props) => (
<div>
{fetchState.status === "loading" ? (
<p>Loading...</p>
) : fetchState.status === "success" ? (
<p>{fetchState.data}</p>
) : fetchState.status === "error" ? (
<p>Oops, an error occured</p>
) : null}
</div>
);
嵌套的三元运算符。它们有点难以阅读,但我们似乎没有更好的选择。如果我们想在分支中定义并重用一个变量,这看起来很基本,但三元运算符却无法做到。如果我们不想设置默认情况,只是想确保我们处理了所有可能的情况呢?这被称为穷尽性检查,猜猜看:三元运算符也无法做到。
🔍 穷尽性检查的现状
有一些方法可以让TypeScript检查switch
语句是否穷尽。其中一种是调用一个接受never
类型参数的函数:
typescript
// 这个函数只是告诉TypeScript这段代码
// 永远不应该被执行。
function safeGuard(arg: never) {}
switch (fetchState.status) {
case "loading":
return <p>Loading...</p>;
case "success":
return <p>{fetchState.data}</p>;
case "error":
return <p>Oops, an error occured</p>;
default:
safeGuard(fetchState.status);
}
如果status
的类型是never
,这意味着所有可能的情况都已处理,那么这段代码才会通过类型检查。这看起来是个不错的解决方案,但如果我们在JSX中使用它,我们又回到了 IIFE:
typescript
<div>
{(() => {
switch (fetchState.status) {
case "loading":
return <p>Loading...</p>;
case "success":
return <p>{fetchState.data}</p>;
case "error":
return <p>Oops, an error occured</p>;
default:
safeGuard(fetchState.status);
}
})()}
</div>;
更多的样板代码。
如果我们想根据两个值而不是一个值来进行分支呢?假设我们想编写一个状态reducer。在防止无效状态变化方面,基于当前状态和动作进行分支被认为是一种好的实践。我们唯一能确保处理每个情况的方法是嵌套多个switch
语句:
typescript
type State =
| { status: "idle" }
| { status: "loading"; startTime: number }
| { status: "success"; data: string }
| { status: "error"; error: Error };
type Action =
| { type: "fetch" }
| { type: "success"; data: string }
| { type: "error"; error: Error }
| { type: "cancel" };
const reducer = (state: State, action: Action): State => {
switch (state.status) {
case "loading": {
switch (action.type) {
case "success": {
return {
status: "success",
data: action.data,
};
}
case "error": {
return {
status: "error",
error: action.error,
};
}
case "cancel": {
// 只有在请求发送后不到2秒的情况下才取消。
if (state.startTime + 2000 < Date.now()) {
return {
status: "idle",
};
} else {
return state;
}
}
default: {
return state;
}
}
}
default:
switch (action.type) {
case "fetch": {
return {
status: "loading",
startTime: Date.now(),
};
}
default: {
return state;
}
}
safeGuard(state.status);
safeGuard(action.type);
}
};
尽管这样更安全,但代码量很大,而且很容易选择更短但不安全的替代方案:只对动作进行switch
。
🤔 难道没有更好的方法吗?
当然有。我们再次需要关注函数式编程语言,看看它们一直以来是如何做到的:模式匹配。
模式匹配是许多语言(如Haskell、OCaml、Erlang、Rust、Swift、Elixir、Rescript等)中的一项功能。甚至有一个2017年的TC39提案,提议将模式匹配添加到EcmaScript规范(定义JavaScript语法和语义)中。提议的语法如下:
typescript
// 实验性EcmaScript模式匹配语法(截至2023年3月)
match (fetchState) {
when ({ status: "loading" }): <p>Loading...</p>
when ({ status: "success", data }): <p>{data}</p>
when ({ status: "error" }): <p>Oops, an error occured</p>
}
模式匹配表达式以match
关键字开头,后面是我们想要分支的值。每个代码分支以when
关键字开头,后面是模式:我们想要匹配的值的形状,只有当这个分支才会被执行。如果你熟悉解构赋值,这应该会很熟悉。
以下是使用该提议语法的前面的reducer示例:
typescript
// 实验性EcmaScript模式匹配语法(截至2023年3月)
const reducer = (state: State, action: Action): State => {
return match ([state, action]) {
when ([{ status: 'loading' }, { type: 'success', data }]): ({
status: 'success',
data,
})
when ([{ status: 'loading' }, { type: 'error', error }]): ({
status: 'error',
error,
})
when ([state, { type: 'fetch' }])
if (state.status !== 'loading'): ({
status: 'loading',
startTime: Date.now(),
})
when ([{ status: 'loading', startTime }, { type: 'cancel' }])
if (startTime + 2000 < Date.now()): ({
status: 'idle',
})
when (_): state
}
};
好多了!
我没有进行任何科学研究,但我相信 模式匹配利用了我们大脑对模式识别 的自然能力。一个模式看起来像是我们想要匹配的值的形状,这使得代码比一堆if
和else
更容易阅读。它也更简洁,最重要的是,它是一个表达式!
我非常期待这个提议,但它仍然处于第一阶段,至少在未来几年内(如果有的话)不太可能被实现。
🚀 将模式匹配引入TypeScript
一年前,我开始开发一个实验性库,为TypeScript实现模式匹配:ts-pattern。起初,我没有期望能够在用户空间实现一个接近原生语言支持的可用性 和类型安全性的东西。结果证明我错了。经过几个月的工作,我意识到TypeScript的类型系统足够强大,可以实现一个具有所有我们期望从原生语言支持中获得的特性的模式匹配库。
今天,我发布了ts-pattern的3.0版本 🥳🎉✨
以下是使用ts-pattern 编写的相同reducer:
typescript
import { match, P } from 'ts-pattern';
const reducer = (state: State, action: Action) =>
match<[State, Action], State>([state, action])
.with([{ status: 'loading' }, { type: 'success', data: P.select() }], data => ({
status: 'success',
data,
}))
.with([{ status: 'loading' }, { type: 'error', error: P.select() }], error => ({
status: 'error',
error,
}))
.with([{ status: P.not('loading') }, { type: 'fetch' }], () => ({
status: 'loading',
startTime: Date.now(),
}))
.with([{ status: 'loading', startTime: P.when(t => t + 2000 < Date.now()) }, { type: 'fetch' }], () => ({
status: 'idle',
}))
.with(P._, () => state) // `P._` 是捕获所有模式。
.exhaustive();
🎨 完美契合声明式上下文
ts-pattern
可以在任何(TypeScript)环境中使用,并且与任何框架或技术兼容。以下是前面的React组件示例:
typescript
declare let fetchState:
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error" };
<div>
{match(fetchState)
.with({ status: "loading" }, () => <p>Loading...</p>)
.with({ status: "success" }, ({ data }) => <p>{data}</p>)
.with({ status: "error" }, () => <p>Oops, an error occured</p>)
.exhaustive()}
</div>;
无需 IIFE 、safeGuard
函数或嵌套三元运算符。它可以直接嵌入你的JSX中。
✅ 兼容任何数据结构
模式可以是任何东西:对象、数组、元组、Maps、Sets,以任何可能的方式嵌套:
typescript
declare let x: unknown;
const output = match(x)
// 字面量
.with(1, (x) => ...)
.with("hello", (x) => ...)
// 支持传递多个模式:
.with(null, undefined, (x) => ...)
// 对象
.with({ x: 10, y: 10 }, (x) => ...)
.with({ position: { x: 0, y: 0 } }, (x) => ...)
// 数组
.with(P.array({ firstName: P.string }), (x) => ...)
// 元组
.with([1, 2, 3], (x) => ...)
// Maps
.with(new Map([["key", "value"]]), (x) => ...)
// Set
.with(new Set(["a"]), (x) => ...)
// 混合 & 嵌套
.with(
[
{ type: "user", firstName: "Gabriel" },
{ type: "post", name: "Hello World", tags: ["typescript"] }
],
(x) => ...)
// 这相当于 `.with(__, () => ...).exhaustive();`
.otherwise(() => ...)
此外,类型系统会拒绝任何与输入类型不匹配的模式!
🛡️ 以类型安全和类型推断为设计目标
对于每个.with(pattern, handler)
子句,输入值会被传递到handler
函数中,类型被缩小到pattern
匹配的内容。
typescript
type Action =
| { type: "fetch" }
| { type: "success"; data: string }
| { type: "error"; error: Error }
| { type: "cancel" };
match<Action>(action)
.with({ type: "success" }, (matchedAction) => {
/* matchedAction: { type: 'success'; data: string } */
})
.with({ type: "error" }, (matchedAction) => {
/* matchedAction: { type: 'error'; error: Error } */
})
.otherwise(() => {
/* ... */
});
✅ 支持穷尽性检查
ts-pattern
通过将穷尽匹配作为默认设置,促使你编写更安全的代码:
typescript
type Action =
| { type: 'fetch' }
| { type: 'success'; data: string }
| { type: 'error'; error: Error }
| { type: 'cancel' };
return match(action)
.with({ type: 'fetch' }, () => /* ... */)
.with({ type: 'success' }, () => /* ... */)
.with({ type: 'error' }, () => /* ... */)
.with({ type: 'cancel' }, () => /* ... */)
.exhaustive(); // 这可以编译
return match(action)
.with({ type: 'fetch' }, () => /* ... */)
.with({ type: 'success' }, () => /* ... */)
.with({ type: 'error' }, () => /* ... */)
// 这无法编译!
// 它会抛出一个 `NonExhaustiveError<{ type: 'cancel' }>` 编译错误。
.exhaustive();
如果你真的需要,可以使用.run()
而不是.exhaustive()
来选择退出:
typescript
return match(action)
.with({ type: 'fetch' }, () => /* ... */)
.with({ type: 'success' }, () => /* ... */)
.with({ type: 'error' }, () => /* ... */)
.run(); // ⚠️ 这是不安全的,但它可以编译
🌟 通配符
如果你需要一个总是匹配的模式,可以使用P._
(通配符)模式。这是一个匹配任何内容的模式:
typescript
import { match, P } from 'ts-pattern';
match([state, event])
.with(P._, () => state)
// 你也可以在另一个模式中使用它:
.with([P._, { type: 'success' }], ([_, event]) => /* event: { type: 'success', data: string } */)
// 在任何级别:
.with([P._, { type: P._ }], () => state)
.exhaustive();
你还可以使用P.string
、P.boolean
和P.number
来匹配特定类型的输入。在处理来自API端点的unknown
值时,这特别有用:
typescript
import { match, P } from "ts-pattern";
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
type User = { firstName: string; age: number; isNice: boolean };
declare let apiResponse: unknown;
const maybeUser = match<unknown, Option<User>>(apiResponse)
.with({ firstName: P.string, age: P.number, isNice: P.boolean }, (user) =>
/* user: { firstName: string, age: number, isNice: boolean } */
({ kind: "some", value: user })
)
.otherwise(() => ({ kind: "none" }));
// maybeUser: Option<User>
🔍 when子句
你可以使用when
辅助函数来确保输入符合一个保护函数:
typescript
import { match, P } from 'ts-pattern';
const isOdd = (x: number) => Boolean(x % 2)
match({ x: 2 })
.with({ x: P.when(isOdd) }, ({ x }) => /* `x` 是奇数 */)
.with(P._, ({ x }) => /* `x` 是偶数 */)
.exhaustive();
你也可以调用.with()
,将保护函数作为第二个参数:
typescript
declare let input: number | string;
match(input)
.with(P.number, isOdd, (x) => /* `x` 是一个奇数 */)
.with(P.string, (x) => /* `x` 是一个字符串 */)
// 无法编译!缺少偶数情况。
.exhaustive();
或者直接使用.when()
:
typescript
match(input)
.when(isOdd, (x) => /* ... */)
.otherwise(() => /* ... */);
🔗 属性选择
在匹配深层嵌套的输入时,通常很方便从输入中提取部分数据以在处理器中使用,避免单独解构输入。select
辅助函数可以实现这一点:
typescript
import { match, select } from "ts-pattern";
type input =
| { type: "text"; content: string }
| { type: "video"; content: { src: string; type: string } };
match(input)
// 匿名选择直接作为第一个参数传递:
.with(
{ type: "text", content: P.select() },
(content) => <p>{content}</p> /* content: string */
)
// 命名选择以 `selections` 对象的形式传递:
.with(
{ type: "video", content: { src: P.select("src"), type: P.select("type") } },
({ src, type }) => (
<video>
<source src={src} type={type} />
</video>
)
)
.exhaustive();
📦 轻量级
由于这个库主要是类型级别的代码,所以它的打包体积非常小 :仅1.6kB ,经过压缩和gzip处理后!
⚠️ 缺点
为了让类型推断和穷尽性检查正常工作,ts-pattern
依赖于类型级别的计算,这可能会减慢项目的类型检查速度。我尝试(并且将继续尝试)使其尽可能快速,但它总是会比switch
语句慢。使用ts-pattern
意味着用一些编译时间换取类型安全性和更容易维护的代码。如果你不喜欢这种权衡,那也没关系!你可以选择不用它!
🔗 安装
你可以从npm安装:
bash
npm install ts-pattern
或者使用yarn:
bash
yarn add ts-pattern
🎉 总结
我喜欢那些能让编写更好代码变得容易的工具。在这方面,我深受ImmutableJS和Immer的启发。仅仅通过提供一个更友好的API来操作不可变数据结构,这些库极大地推动了不可变性在行业中的采用。
模式匹配之所以出色,是因为它促使我们编写更安全、更易读的代码,而ts-pattern
是我尝试在TypeScript社区推广这一概念的 humble attempt(谦虚尝试)。ts-pattern v3.0
是第一个LTS版本。现在技术难题已经解决,这个版本专注于性能和可用性。希望你会喜欢它。
✨ 如果你觉得它很酷,在GitHub上给它点个赞吧!
你可以在这里找到完整的API参考 :ts-pattern 仓库
PS:我们不应该直接切换到支持模式匹配的语言吗?
像Rescript这样的语言支持模式匹配并且可以编译为JS。如果我要开始一个新项目,我个人很乐意尝试它们!不过,我们并不总是有幸从头开始一个新项目,而我们的TypeScript代码可以从采用模式匹配中受益匪浅。我的代码肯定可以。希望我说服了你。
PPS:灵感来源
这个库深受Wim Jongeneel的优秀文章《TypeScript中的模式匹配:使用记录和通配符模式》的启发。如果你想大致了解ts-pattern
在底层是如何工作的,可以阅读这篇文章。