你已經讀過五篇關於 monads 的部落格文章。每篇文章都從範疇論開始,提到墨西哥捲餅,卻讓你比之前更困惑。讓我們跳過所有這些。

這裡是簡短版本:monad 是一個支援三種操作的容器。你每天在 TypeScript 中已經使用兩個 monads — PromiseArray。一旦你看到模式,@oofp/core 中的每個 monad 都會讓你感到熟悉。

三種操作

每個 monad 都有三件事:

  1. of(a) — 將值放入容器中。
  2. map(f) — 轉換容器內的值,保持容器形狀。
  3. chain(f) — 將值轉換為新的容器,然後展平。

就是這樣。如果一個型別支援這三種操作並遵循一些簡單的法則,它就是 monad。讓我們用你已經知道的型別來看看。

Promise — 你每天使用的 monad

// 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 — 另一個隱藏在眼前的 monad

// 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:消除 null 檢查

現在你已經看到模式,讓我們將它應用到實際問題。考慮這段程式碼:

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

對一個值進行三次 null 檢查。可選鏈(user?.address?.city?.toUpperCase() ?? "UNKNOWN")有幫助,但它無法組合。你無法重複使用這些邏輯片段,且回退值被埋在結尾。

Maybe 是一個代表值可能不存在的 monad。它有兩種狀態: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 會自動傳播。無需 null 檢查、無需提前回傳、無需例外。

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 是一個 chainLeft 時會短路的 monad。如果 validateEmail 失敗,validateAge 從未執行。錯誤型別會通過管道傳遞,編譯器知道它。

要深入了解基於 Either 的錯誤處理,包括非同步操作和恢復模式,請參閱 TypeScript 中的函數式錯誤處理

Task:延遲非同步

Promise 有個問題:它是急切的。當你建立一個時,它立即開始執行:

// 這會立即觸發,無論你是否想要
const result = fetch("/api/users");

Enter fullscreen mode Exit fullscreen mode

你無法在不觸發其副作用的情況下傳遞 Promise。你無法在不執行它們的情況下組合 Promises。這破壞了引用透明性 — 你無法在不改變程式行為的情況下用其值替換表達式。

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 只是一個函式,你可以:

  • 在不觸發副作用的情況下傳遞它。
  • 在執行任何操作之前將多個 Tasks 組合成管道。
  • 根據其他值重試、延遲或條件性地執行 Tasks。

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 中最常使用的 monad。有關重試策略、使用 orElse 進行錯誤恢復以及使用 concurrencyObject 進行並發執行,請參閱 錯誤處理指南

chain 模式

這裡是將所有內容聯繫在一起的關鍵洞察。chain 是使 monads 強大的原因。它是順序組合,每個步驟都可以改變容器的狀態:

  • Maybe.chainJust 可以變成 Nothing。一旦是 Nothing,其餘部分就會被跳過。
  • Either.chainRight 可以變成 Left。錯誤會傳播,其餘部分被跳過。
  • Task.chain — 一個非同步操作將其結果提供給下一個操作。透過建構進行順序處理。
  • TaskEither.chain — 序列化可能失敗的非同步操作。如果某一步產生 Left,後續步驟就會被跳過。

每個 monad 都遵循相同的結構。學一次 chain,你就可以使用任何 monad。

這裡是一個流經多個 monads 的實用範例:

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

三個不同的 monads,三個不同的關注點(缺失值、驗證錯誤、非同步失敗),但整個過程中都是相同的 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

何時使用什麼

情況 Monad 原因
值可能不存在 Maybe 消除 null 檢查,傳播不存在狀態
操作可能失敗(同步) 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 序列化操作,並在邊界處提取結果。一旦你內化了這一點,每個 monad 都只是同一主題的變化。