将模式匹配引入 TypeScript 🎨 介绍 TS-Pattern
将模式匹配引入 TypeScript
在过去的几年里,前端开发变得越来越声明式。React将我们的思维模式从命令式地操作 DOM 转变为以声明式的方式表达 DOM 在给定状态下应该是什么样子。它已被业界广泛采用,现在我们已经意识到,采用这种范式可以更容易地推理声明式代码,并且可以避免多少 bug,因此我们绝不会再走回头路了。
不仅仅是用户界面,状态管理库也开始转向声明式编程。XState 、Redux等库允许您以声明式方式管理应用程序状态,从而带来同样的好处:编写更易于理解、修改和测试的代码。如今,我们真正生活在一个声明式编程的世界里!
然而,Javascript 和 TypeScript 并不是为这种范式而设计的,这些语言缺少一个非常重要的难题:声明性代码分支。
声明式编程本质上是由定义表达式而非语句组成的——即求值的代码。其核心思想是将描述需要执行的操作的代码与解释此描述以产生副作用的代码分离。例如,开发一个 React 应用本质上包括使用 JSX 描述 DOM 的外观,并让 React 在底层以高性能的方式修改 DOM。
的问题if
,else
和switch
如果你用过 React,你可能会注意到 JSX 内部的代码分支并不简单。我们习惯使用的if
、else
或语句的唯一方法是在自调用函数(也称为立即调用函数表达式,简称IIFE)中:switch
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>;
}
})() // Immediately invoke the function
}
</div>;
这代码太多了,看起来不太美观。这不能怪 React —— 只是像,和这样的命令式语句(它们不返回任何值)不太适合声明式的上下文。我们需要用表达式来代替。if
else
switch
JavaScript 确实有一种编写代码分支表达式的方法:三元表达式。但它们有几个限制……
三元组还不够
三元运算符是一种基于布尔值返回两个不同值的简洁方法:
bool ? valueIfTrue : valueIfFalse;
三元运算符本身就是表达式,这一简单事实使其成为React 中编写代码分支的默认方式。我们现在大多数组件的样式如下:
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
类型参数的函数:
// This function is just a way to tell TypeScript that this code
// should never be executed.
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了:
<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 语句:
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": {
// only cancel if the request was sent less than 2 sec ago.
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);
}
};
尽管这样更安全,但代码量很大,而且人们很容易选择更短、更不安全的替代方案:仅切换操作。
一定有更好的方法来做到这一点?
当然有。再次,我们需要把目光转向函数式编程语言,看看它们一直以来是如何做的:模式匹配。
模式匹配是许多语言都实现的功能,例如 Haskell、OCaml、Erlang、Rust、Swift、Elixir、Rescript……等等。2017年,TC39 甚至提出了一项提案,要求将模式匹配添加到 EcmaScript 规范(定义 JavaScript 语法和语义)中。提案的语法如下所示:
// Experimental EcmaScript pattern matching syntax (as of March 2023)
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 示例与提案的对应关系:
// Experimental EcmaScript pattern matching syntax (as of March 2023)
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
“s”和else
“s”更容易阅读。它也更短,最重要的是,它是一个表达式!
我对这个提议感到非常兴奋,但它仍处于第一阶段,至少在几年内不太可能实施(如果有的话)。
将模式匹配引入 TypeScript
一年前,我开始开发一个当时还处于实验阶段的 TypeScript 模式匹配库:ts-pattern。起初,我并没有想到能够在用户空间实现在可用性和类型安全性方面接近原生语言支持的功能。结果证明我错了。经过几个月的努力,我意识到 TypeScript 的类型系统足够强大,足以实现一个模式匹配库,并具备原生语言支持所能提供的所有功能。
今天,我发布了ts-pattern 3.0 版本🥳🎉✨
下面是用ts-pattern编写的相同 reducer:
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._` is the catch-all pattern.
.exhaustive();
`
完美契合声明式语境
ts-pattern
适用于任何(TypeScript)环境、任何框架或技术。以下是之前的 React 组件示例:
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。
与任何数据结构兼容
模式可以是任何东西:对象、数组、元组、映射、集合,以任何可能的方式嵌套:
declare let x: unknown;
const output = match(x)
// Literals
.with(1, (x) => ...)
.with("hello", (x) => ...)
// Supports passing several patterns:
.with(null, undefined, (x) => ...)
// Objects
.with({ x: 10, y: 10 }, (x) => ...)
.with({ position: { x: 0, y: 0 } }, (x) => ...)
// Arrays
.with(P.array({ firstName: P.string }), (x) => ...)
// Tuples
.with([1, 2, 3], (x) => ...)
// Maps
.with(new Map([["key", "value"]]), (x) => ...)
// Set
.with(new Set(["a"]), (x) => ...)
// Mixed & nested
.with(
[
{ type: "user", firstName: "Gabriel" },
{ type: "post", name: "Hello World", tags: ["typescript"] }
],
(x) => ...)
// This is equivalent to `.with(__, () => …).exhaustive();`
.otherwise(() => ...)
此外,类型系统将拒绝任何与输入类型不匹配的模式!
构建时考虑了类型安全和类型推断
对于每个.with(pattern, handler)
子句,输入值都会通过管道传输到handler
函数,其类型会缩小到pattern
匹配的范围。
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
通过使详尽匹配成为默认值,推动您使用更安全的代码:
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(); // This compiles
return match(action)
.with({ type: 'fetch' }, () => /* ... */)
.with({ type: 'success' }, () => /* ... */)
.with({ type: 'error' }, () => /* ... */)
// This doesn't compile!
// It throws a `NonExhaustiveError<{ type: 'cancel' }>` compilation error.
.exhaustive();
如果您确实需要,您仍然可以使用.run()
而不是选择退出:.exhaustive()
return match(action)
.with({ type: 'fetch' }, () => /* ... */)
.with({ type: 'success' }, () => /* ... */)
.with({ type: 'error' }, () => /* ... */)
.run(); // ⚠️ This is unsafe but it compiles
通配符
如果你需要一个总是匹配的模式,你可以使用P._
通配符模式。这是一个可以匹配任何内容的模式:
import { match, P } from 'ts-pattern';
match([state, event])
.with(P._, () => state)
// You can also use it inside another pattern:
.with([P._, { type: 'success' }], ([_, event]) => /* event: { type: 'success', data: string } */)
// at any level:
.with([P._, { type: P._ }], () => state)
.exhaustive();
也可以使用,和来匹配特定类型的输入。这在处理可能来自 API 端点的值时特别有用:P.string
P.boolean
P.number
unknown
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
辅助函数来确保输入符合保护函数:
import { match, P } from 'ts-pattern';
const isOdd = (x: number) => Boolean(x % 2)
match({ x: 2 })
.with({ x: P.when(isOdd) }, ({ x }) => /* `x` is odd */)
.with(P._, ({ x }) => /* `x` is even */)
.exhaustive();
您还可以.with()
使用保护函数作为第二个参数进行调用:
declare let input: number | string;
match(input)
.with(P.number, isOdd, (x) => /* `x` is an odd number */)
.with(P.string, (x) => /* `x` is a string */)
// Doesn't compile! the even number case is missing.
.exhaustive();
或者直接使用.when()
:
match(input)
.when(isOdd, (x) => /* ... */)
.otherwise(() => /* ... */);
房产选择
当匹配深层嵌套的输入时,通常最好提取输入的各个部分用于处理程序,以避免单独解构输入。select
辅助函数可以让你做到这一点:
import { match, select } from "ts-pattern";
type input =
| { type: "text"; content: string }
| { type: "video"; content: { src: string; type: string } };
match(input)
// Anonymous selections are directly passed as first parameter:
.with(
{ type: "text", content: P.select() },
(content) => <p>{content}</p> /* content: string */
)
// Named selections are passed in a `selections` object:
.with(
{ type: "video", content: { src: P.select("src"), type: P.select("type") } },
({ src, type }) => (
<video>
<source src={src} type={type} />
</video>
)
)
.exhaustive();
微小的
由于这个库主要是类型级代码,因此它的包占用空间很小:最小化和压缩后只有 1.6kB !
缺点
为了使类型推断和详尽性检查正常工作,ts-pattern
依赖于类型级计算,这可能会减慢项目的类型检查速度。我尝试(并将继续尝试)使其尽可能快,但它总是比switch
语句慢。使用ts-pattern
, 意味着牺牲一些编译时间,以换取类型安全性和更易于维护的代码。如果这种权衡对你来说不具吸引力,没关系!你不必使用它!
安装
你可以从npm安装它
npm install ts-pattern
或纱线
yarn add ts-pattern
结论
我喜欢那些能轻松编写更优质代码的工具。在这方面, ImmutableJS和Immer给了我很大的启发。这些库仅仅通过提供更优秀的 API 来操作不可变数据结构,就极大地促进了行业对不可变性的采用。
模式匹配非常棒,因为它能帮助我们编写更安全、更易读的代码,这也是ts-pattern
我试图在 TypeScript 社区推广这一概念的一点小小尝试。ts -pattern v3.0是第一个 LTS 版本。现在,技术难题已经解决,此版本将专注于性能和可用性。希望你会喜欢它。
✨如果您认为它令人兴奋,请在 GitHub 上为它加星标✨!
您可以在ts-pattern 存储库中找到完整的 API 参考
👉我在 Hacker News 上发布了链接,如果您有任何问题,请随时在帖子中发表评论,我会尽力回答每个人!
PS:我们不应该切换到支持模式匹配的语言吗?
有些语言,比如Rescript,支持模式匹配并编译为 JS。如果我要开始一个新项目,我个人很乐意尝试一下!虽然我们并不总是有条件从头开始一个新项目,但我们编写的 TypeScript 代码如果采用模式匹配,肯定会受益匪浅。我的代码就一定受益匪浅。希望我的案例能让你信服 😉
PPS:灵感
这个库很大程度上受到了 Wim Jongeneel 的优秀文章《TypeScript 中的模式匹配与记录和通配符模式》的启发。如果你想大致了解 ts-pattern 的底层工作原理,可以读一读这篇文章。
👋 再见!
[2023 年 4 月更新]:更新示例以使用 TS-Pattern v4 而不是 v3。
文章来源:https://dev.to/gvergnaud/bringing-pattern-matching-to-typescript-introducing-ts-pattern-v3-0-o1k