你已经阅读了五篇关于单子的博客文章。每篇文章都从范畴论开始,提到了墨西哥卷饼,却让你比之前更加困惑。让我们跳过所有这些。

简短版本是:单子是一个支持三种操作的容器。你每天已经在 TypeScript 中使用两种单子——PromiseArray。一旦你看到这种模式,@oofp/core 中的每一个单子都会感觉很熟悉。

三种操作

每个单子都有三件事:

  1. of(a) — 将值放入容器中。
  2. map(f) — 转换内部的值,保持容器形状。
  3. 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 同时充当 mapchain——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.mapmap.flatMapchain。相同的模式,不同的容器。

关键是:chain 就是 map 后跟 flatten。这就是为什么它也被称为 flatMapbind。你应用一个返回新容器的函数,然后展平嵌套结果。

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

逐步说明:

  1. M.fromNullable(user) — 如果 usernullundefined,返回 Nothing。否则返回 Just(user)
  2. M.chain(u => M.fromNullable(u.address)) — 如果我们有用户,尝试获取地址。如果缺失,整个链变为 Nothing
  3. M.chain(a => M.fromNullable(a.city)) — 对 city 也一样。
  4. M.map(city => city.toUpperCase()) — 如果我们仍有值,转换它。
  5. M.getOrElse(() => "UNKNOWN") — 提取值,或使用回退值。

关键点:一旦任何步骤产生 Nothing,所有后续的 mapchain 都会被跳过。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 全栈应用架构

对于同步代码,从 MaybeEither 开始。当你需要带类型化错误的异步操作时,转向 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

在文档中探索每个模块:

模式始终相同:将值包装在容器中,用 map 转换它,用 chain 序列化操作,并在边界处提取结果。一旦你内化了这一点,每个单子都只是同一主题的变体。