你已经阅读了五篇关于单子的博客文章。每篇文章都从范畴论开始,提到了墨西哥卷饼,却让你比之前更加困惑。让我们跳过所有这些。
简短版本是:单子是一个支持三种操作的容器。你每天已经在 TypeScript 中使用两种单子——Promise 和 Array。一旦你看到这种模式,@oofp/core 中的每一个单子都会感觉很熟悉。
三种操作
每个单子都有三件事:
-
of(a)— 将值放入容器中。 -
map(f)— 转换内部的值,保持容器形状。 -
chain(f)— 将值转换为新的容器,然后展平。
就是这样。如果一个类型支持这三种操作并遵循一些简单法则,它就是一个单子。让我们用你已经知道的类型来看看。
Promise——你每天使用的单子
// of — 包装一个值
const p = Promise.resolve(42);
// map — 转换内部(使用普通返回的 then)
const doubled = p.then((x) => x * 2); // Promise<number>
// chain — 转换为新 Promise(使用异步返回的 then)
const fetched = p.then((id) => fetch(`/api/users/${id}`)); // Promise<Response>
Enter fullscreen mode Exit fullscreen mode
Promise.resolve 就是 of。.then 同时充当 map 和 chain——JavaScript 将它们混为一谈。当你的回调返回普通值时,它是 map。当它返回 Promise 时,它是 chain。运行时会自动将嵌套的 Promise<Promise<T>> 展平为 Promise<T>。
Array——隐藏在明处的另一个单子
// of — 包装一个值
const arr = [42];
// map — 转换每个元素
const doubled = arr.map((x) => x * 2); // [84]
// chain — 将每个元素转换为数组,然后展平
const expanded = arr.flatMap((x) => [x, x * 10]); // [42, 420]
Enter fullscreen mode Exit fullscreen mode
Array.of(或仅 [value])就是 of。.map 是 map。.flatMap 是 chain。相同的模式,不同的容器。
关键是:chain 就是 map 后跟 flatten。这就是为什么它也被称为 flatMap 或 bind。你应用一个返回新容器的函数,然后展平嵌套结果。
Maybe:消除空值检查
既然你已经看到了模式,让我们将它应用到一个实际问题上。考虑这段代码:
interface User {
name: string;
address?: {
city?: string;
zip?: string;
};
}
function getCityUppercase(user: User | null): string {
if (user === null) return "UNKNOWN";
if (!user.address) return "UNKNOWN";
if (!user.address.city) return "UNKNOWN";
return user.address.city.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode
一个值需要三次空值检查。可选链可以帮助(user?.address?.city?.toUpperCase() ?? "UNKNOWN"),但它不能组合。你无法重用这些逻辑片段,回退值也埋在最后。
Maybe 是一个表示值可能不存在的单子。它有两种状态:Just(value) 或 Nothing。以下是相同的逻辑:
import * as M from "@oofp/core/maybe";
import { pipe } from "@oofp/core/pipe";
const getCityUppercase = (user: User | null): string =>
pipe(
M.fromNullable(user),
M.chain((u) => M.fromNullable(u.address)),
M.chain((a) => M.fromNullable(a.city)),
M.map((city) => city.toUpperCase()),
M.getOrElse(() => "UNKNOWN"),
);
Enter fullscreen mode Exit fullscreen mode
逐步说明:
-
M.fromNullable(user)— 如果user是null或undefined,返回Nothing。否则返回Just(user)。 -
M.chain(u => M.fromNullable(u.address))— 如果我们有用户,尝试获取地址。如果缺失,整个链变为Nothing。 -
M.chain(a => M.fromNullable(a.city))— 对 city 也一样。 -
M.map(city => city.toUpperCase())— 如果我们仍有值,转换它。 -
M.getOrElse(() => "UNKNOWN")— 提取值,或使用回退值。
关键点:一旦任何步骤产生 Nothing,所有后续的 map 和 chain 都会被跳过。Nothing 会自动传播。没有空值检查,没有提前返回,没有异常。
Maybe API 一览
import * as M from "@oofp/core/maybe";
M.just(42); // Just(42)
M.nothing(); // Nothing
M.fromNullable(null); // Nothing
M.fromNullable(42); // Just(42)
// 转换
M.map((x) => x + 1); // Just(42) -> Just(43), Nothing -> Nothing
M.chain((x) => M.just(x + 1)); // Just(42) -> Just(43)
// 提取
M.getOrElse(() => 0); // Just(42) -> 42, Nothing -> 0
M.toNullable; // Just(42) -> 42, Nothing -> null
Enter fullscreen mode Exit fullscreen mode
Either:将错误作为值
Maybe 告诉你某物是否缺失,但不会告诉你原因。Either<E, A> 解决了这个问题。它有两种状态:Right(value) 表示成功,Left(error) 表示失败。错误类型 E 在签名中是显式的。
import * as E from "@oofp/core/either";
import { pipe } from "@oofp/core/pipe";
type ValidationError = { field: string; message: string };
const validateEmail = (input: string): E.Either<ValidationError, string> => {
if (!input.includes("@"))
return E.left({ field: "email", message: "Must contain @" });
return E.right(input);
};
const validateAge = (input: number): E.Either<ValidationError, number> => {
if (input < 0 || input > 150)
return E.left({ field: "age", message: "Out of range" });
return E.right(input);
};
const validateUser = (data: { email: string; age: number }) =>
pipe(
validateEmail(data.email),
E.chain(() => validateAge(data.age)),
E.map(() => data),
);
Enter fullscreen mode Exit fullscreen mode
Either 是一个单子,其中 chain 在遇到 Left 时会短路。如果 validateEmail 失败,validateAge 永远不会运行。错误类型会贯穿整个管道,编译器也知道它。
要深入了解基于 Either 的错误处理,包括异步操作和恢复模式,请参阅 TypeScript 中的函数式错误处理。
Task:惰性异步
Promise 有一个问题:它是急切的。你创建它的那一刻,它就开始执行:
// 无论你是否需要,这都会立即触发
const result = fetch("/api/users");
Enter fullscreen mode Exit fullscreen mode
你无法在不触发其副作用的情况下传递 Promise。你无法在不运行它们的情况下组合 Promise。这破坏了引用透明性——你无法在不改变程序行为的情况下用其值替换表达式。
Task<A> 解决了这个问题。它是一个惰性的 Promise:
type Task<A> = () => Promise<A>;
Enter fullscreen mode Exit fullscreen mode
Task 是一个函数,当被调用时会产生一个 Promise。在你调用它之前什么都不会执行。
import * as T from "@oofp/core/task";
import { pipe } from "@oofp/core/pipe";
// 定义计算——现在什么都不会运行
const fetchUsers = pipe(
T.of("/api/users"),
T.chain((url) => () => fetch(url)),
T.chain((res) => () => res.json()),
T.map((data) => data.users),
);
// 仍然什么都没发生。
// 现在它运行了:
const users = await fetchUsers();
Enter fullscreen mode Exit fullscreen mode
因为 Task 只是一个函数,你可以:
- 在不触发副作用的情况下传递它。
- 在执行任何任务之前将多个 Task 组合成管道。
- 根据其他值重试、延迟或有条件地运行 Task。
Task API 一览
import * as T from "@oofp/core/task";
T.of(42); // 解析为 42 的 Task
T.map((x) => x + 1); // 转换解析后的值
T.chain((x) => T.of(x + 1)); // 序列化为新 Task
T.delay(1000); // 等待 1 秒再解析
Enter fullscreen mode Exit fullscreen mode
TaskEither:主力军
在实际应用中,大多数操作既是异步的又可能失败。TaskEither<E, A> 结合了两者:
type TaskEither<E, A> = () => Promise<Either<E, A>>;
Enter fullscreen mode Exit fullscreen mode
它是一个惰性函数,返回一个 Promise,该 Promise 始终解析 为 Either。没有拒绝,没有未处理的 Promise 错误——只有类型化的成功或类型化的失败。
import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";
interface ApiError {
status: number; message: string;
}
interface User {
id: string; name: string;
}
interface Order {
id: string; total: number;
}
const fetchUser = (id: string): TE.TaskEither<ApiError, User> =>
TE.tryCatch(
() =>
fetch(`/api/users/${id}`).then(async (res) => {
if (!res.ok) throw { status: res.status, message: res.statusText };
return res.json() as Promise<User>;
}),
(err) => err as ApiError,
);
const fetchOrders = (userId: string): TE.TaskEither<ApiError, Order[]> =>
TE.tryCatch(
() =>
fetch(`/api/orders?user=${userId}`).then(async (res) => {
if (!res.ok) throw { status: res.status, message: res.statusText };
return res.json() as Promise<Order[]>;
}),
(err) => err as ApiError,
);
// 组合完整的工作流
const getUserDashboard = (userId: string) =>
pipe(
fetchUser(userId),
TE.chain((user) =>
pipe(
fetchOrders(user.id),
TE.map((orders) => ({
user,
orders,
totalSpent: orders.reduce((sum, o) => sum + o.total, 0),
})),
),
),
);
// 什么都没执行。运行它:
const result = await getUserDashboard("user-123")();
Enter fullscreen mode Exit fullscreen mode
tryCatch 接受一个返回 Promise(可能拒绝)的函数,并将其包装成永不拒绝的 TaskEither。第二个参数将捕获的错误映射到你的类型化错误中。
这是你在生产 TypeScript 中最常用的单子。有关重试策略、使用 orElse 进行错误恢复以及使用 concurrencyObject 进行并发执行,请参阅 错误处理指南。
chain 模式
这里是串联所有内容的关键见解。chain 是使单子强大的原因。它是一种顺序组合,其中每一步都可以改变容器的状态:
-
Maybe.chain— 一个Just可以变成Nothing。一旦是Nothing,其余部分就会被跳过。 -
Either.chain— 一个Right可以变成Left。错误会传播,其余部分会被跳过。 -
Task.chain— 一个异步操作将其结果输入到下一个操作中。按构造顺序执行。 -
TaskEither.chain— 序列化可能失败的异步操作。如果某一步产生Left,后续步骤会被跳过。
每个单子都遵循相同的结构。学习一次 chain,你就可以使用任何单子。
这里有一个通过多个单子的实际示例:
import * as M from "@oofp/core/maybe";
import * as E from "@oofp/core/either";
import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";
// 从环境解析配置(同步,可能缺失)
const getConfig = (env: Record<string, string | undefined>) =>
pipe(
M.fromNullable(env.API_URL),
M.chain((url) =>
pipe(
M.fromNullable(env.API_KEY),
M.map((key) => ({ url, key })),
),
),
);
// 验证配置(同步,可能因原因而失败)
const validateConfig = (config: { url: string; key: string }) =>
pipe(
config.url.startsWith("https")
? E.right(config)
: E.left("API_URL must use HTTPS"),
E.chain((c) =>
c.key.length >= 32 ? E.right(c) : E.left("API_KEY too short"),
),
);
// 使用已验证的配置获取数据(异步,可能失败)
const fetchData = (config: { url: string; key: string }) =>
TE.tryCatch(
() =>
fetch(config.url, {
headers: Authorization: `Bearer ${config.key}` },
}).then((r) => r.json()),
(err) => `Fetch failed: ${String(err)}`,
);
Enter fullscreen mode Exit fullscreen mode
三种不同的单子,三个不同的关注点(缺失值、验证错误、异步失败),但贯穿始终的是相同的 chain 模式。每个函数都很小、可测试且可组合。
pipe:将它们粘合在一起
你可能已经注意到每个示例都使用了 pipe。没有它,单子代码会变得深度嵌套:
// 没有 pipe——嵌套且难以阅读
const result = M.getOrElse(() => "UNKNOWN")(
M.map((city: string) => city.toUpperCase())(
M.chain((a: { city?: string }) => M.fromNullable(a.city))(
M.chain((u: User) => M.fromNullable(u.address))(
M.fromNullable(user)
)
)
)
);
Enter fullscreen mode Exit fullscreen mode
使用 pipe,相同的逻辑从上到下、从左到右阅读:
// 使用 pipe——线性且清晰
const result = pipe(
M.fromNullable(user),
M.chain((u) => M.fromNullable(u.address)),
M.chain((a) => M.fromNullable(a.city)),
M.map((city) => city.toUpperCase()),
M.getOrElse(() => "UNKNOWN"),
);
Enter fullscreen mode Exit fullscreen mode
pipe 接受一个初始值并将其传递通过一系列函数。每个函数接收前一个函数的输出。它是 @oofp/core 中组合代码的支柱。
import { pipe } from "@oofp/core/pipe";
// pipe(value, f1, f2, f3) === f3(f2(f1(value)))
const result = pipe(
5,
(x) => x * 2, // 10
(x) => x + 1, // 11
(x) => `${x}!`, // "11!"
);
Enter fullscreen mode Exit fullscreen mode
何时使用什么
| 情况 | 单子 | 原因 |
|---|---|---|
| 值可能不存在 | Maybe |
消除空值检查,传播缺失 |
| 操作可能失败(同步) | Either |
函数签名中的类型化错误 |
| 异步操作 | Task |
惰性、可组合、引用透明 |
| 异步 + 可能失败 | TaskEither |
真实应用的生产主力 |
| 需要运行时上下文 | Reader |
无需全局的依赖注入 |
| 异步 + 失败 + 上下文 | ReaderTaskEither |
全栈应用架构 |
对于同步代码,从 Maybe 和 Either 开始。当你需要带类型化错误的异步操作时,转向 TaskEither。当你需要在线程配置、数据库连接或其他上下文通过应用时,使用 ReaderTaskEither。
开始使用
安装 @oofp/core:
npm install @oofp/core
Enter fullscreen mode Exit fullscreen mode
导入你需要的:
import * as M from "@oofp/core/maybe";
import * as E from "@oofp/core/either";
import * as T from "@oofp/core/task";
import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";
Enter fullscreen mode Exit fullscreen mode
在文档中探索每个模块:
- Maybe — 无空值的可选值
- Either — 同步错误处理
- Task — 惰性异步计算
- TaskEither — 异步错误处理
- Pipe、Flow 和 Compose — 函数组合
模式始终相同:将值包装在容器中,用 map 转换它,用 chain 序列化操作,并在边界处提取结果。一旦你内化了这一点,每个单子都只是同一主题的变体。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.