你已經讀過五篇關於 monads 的部落格文章。每篇文章都從範疇論開始,提到墨西哥捲餅,卻讓你比之前更困惑。讓我們跳過所有這些。
這裡是簡短版本:monad 是一個支援三種操作的容器。你每天在 TypeScript 中已經使用兩個 monads — Promise 和 Array。一旦你看到模式,@oofp/core 中的每個 monad 都會讓你感到熟悉。
三種操作
每個 monad 都有三件事:
-
of(a)— 將值放入容器中。 -
map(f)— 轉換容器內的值,保持容器形狀。 -
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 同時扮演 map 和 chain 的角色 — 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。.map 是 map。.flatMap 是 chain。相同的模式,不同的容器。
關鍵洞察是:chain 就是 map 接著 flatten。這就是為什麼它也被稱為 flatMap 或 bind。你套用一個回傳新容器的函式,然後展平巢狀的結果。
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
逐步說明:
-
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 會自動傳播。無需 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 是一個 chain 在 Left 時會短路的 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.chain—Just可以變成Nothing。一旦是Nothing,其餘部分就會被跳過。 -
Either.chain—Right可以變成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 |
全端應用程式架構 |
對於同步程式碼,從 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 — 無需 null 的可選值
- Either — 同步錯誤處理
- Task — 延遲非同步計算
- TaskEither — 非同步錯誤處理
- Pipe, Flow & Compose — 函式組合
模式始終相同:將值封裝在容器中,使用 map 轉換它,使用 chain 序列化操作,並在邊界處提取結果。一旦你內化了這一點,每個 monad 都只是同一主題的變化。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.