Effect 把錯誤當作一等公民。本文以實作為主,逐步帶你掌握「期望錯誤 vs. 非期望錯誤」、短路語義、常用錯誤處理 combinators,以及在 Effect.gen 裡的三種錯誤處理策略。
我們都知道錯誤在任何程式中都是不可避免的,需要妥善處理它們。Effect 在錯誤處理方面提供了新的安全層級,讓錯誤在型別層級被追蹤,並自動在程式中傳播到最上層。接下來我們來看一個簡單的範例。
我們先定義兩個具 _tag
的錯誤(可判別聯集),再建立一個可能失敗的 effect。
import { Effect } from "effect"
class FooError {
readonly _tag = "FooError"
constructor(readonly message = "Foo failed") {}
}
class BarError {
readonly _tag = "BarError"
constructor(readonly message = "Bar failed") {}
}
const conditions = [true, true, true] as [boolean, boolean, boolean]
const errors = Effect.gen(function*() {
if (conditions[0]) {
return yield* Effect.fail(new FooError())
} else if (conditions[1]) {
return yield* Effect.fail(new BarError())
} else if (conditions[2]) {
return yield* Effect.die("Boom") // 非期望錯誤
}
return "Success"
})
Effect.runSync(errors) // Effect.Effect<string, FooError | BarError, never>
從 error 的輸出型別可以知道,所有 Effect.gen
內部的錯誤結果都提升到外層。這讓我們很好的觀察,errors 這個Effect 具體有可能的錯誤結果有哪些。
在 Effect 中有兩種錯誤類型:
期望錯誤(Expected Errors):也稱為 failures、typed errors 或 recoverable errors,是開發者預期在正常程式執行中可能發生的錯誤。這些錯誤在型別層級被 Effect
資料型別追蹤,編譯器會強制你處理這些錯誤,在定義程式領域和控制流程中扮演重要角色。而讓錯誤變成期望錯誤的方法,就是我們前幾篇文章一直有在使用的 Effect.fail(e)
。當你定義這樣的錯誤時,不僅讓Effect 程式能夠追中錯誤,編譯器也會提醒你要處理這些錯誤。在開發上會讓你更有信心,因為你知道你的程式碼是可預測的,不會有未處理的錯誤。
非期望錯誤(Unexpected Errors):也稱為 defects、untyped errors 或 unrecoverable errors,是開發者不預期在正常程式執行中發生的錯誤。與期望錯誤不同,這些錯誤位於程式預期行為之外。用 Effect.die(cause)
產生,代表程式已處於無效狀態。這種錯誤通常是程式設計錯誤,例如空指標、除以零、索引超出範圍等。這種錯誤通常會導致程式崩潰,因此我們需要特別小心處理。之後會詳細說明如何處理非期望錯誤。
另外值得一提的是,Effect 具有惰性(lazy)特性,會在遇到第一個錯誤時短路(Short-Circuiting)。這意味著如果你有多個可能失敗的操作,預設情況下從錯誤點開始,程式不會繼續執行,除非你主動處理該錯誤。
const program = Effect.gen(function*() {
yield* Console.log("1")
return yield* Effect.fail(new Error("Boom")) // 這行會失敗,程式短路
yield* Console.log("2") // 這行不會執行
})
Effect.runPromise(program).catch((e) => console.error("program:", e))
// 輸出:
/**
1
program: (FiberFailure) Error: Boom at <anonymous> (/Users/eric/personal-project/effect-app/src/day14/Program.ts:45:29)
*/
現在來講講如何處理錯誤。
catchAll
:接住任何期望錯誤,並回傳一個新的 EffectcatchTag
/ catchTags
:精準處理具 _tag
的錯誤(推薦)// ┌─── Effect.Effect<string, never, never>
// ▼
const program = errors.pipe(
Effect.catchAll((e) => Effect.succeed(`Handled ${e._tag}`))
)
Effect.runSync(program) // 什麼輸出沒有
Effect.runPromise(program).then(console.log); // -> "Handled FooError"
我們從上面範例可以知道,catchAll
會接住第一個發生的期望錯誤,並直接回傳一個新的 Effect。就算流程之後還有程式碼,也不會繼續執行。所以 Effect.runPromise(program).then(console.log)
只會輸出 Handled FooError
。而不會接到 BarError
的錯誤。
雖然這樣程式就不會報錯了,但大多時候我們會想對不同錯誤類型做不同的處理,而不是全部用相同方式解決。這時候我們就可以改用 catchTag
來達到目的。
// ┌─── Effect.Effect<string, never, never>
// ▼
const program = errors.pipe(
Effect.catchTags({
FooError: () => Effect.succeed("Handled Foo"),
BarError: () => Effect.succeed("Handled Bar")
})
)
Effect.runSync(program)
觀察一下最終 Effect 的型別,可以發現我們成功處理了兩個錯誤,把有可能發生的錯誤都接住了。所以最後 Effect 的錯誤管道型別為 never
。也就是永不發生錯誤。
// ┌─── Effect.Effect<string, BarError, never>
// ▼
const program = errors.pipe(
Effect.catchTags({
FooError: () => Effect.succeed("Handled Foo"),
})
)
但當只處理其中一個錯誤情境時,那最終 Effect 的錯誤管道型別會是沒被處理的錯誤型別,以這個例子來說是 BarError
。這樣的設計也可以讓我們可以很清楚的知道,有處理了哪些錯誤,哪些錯誤沒有被處理。
// orElse:失敗時提供替代的成功 Effect
// ┌─── Effect.Effect<string, never, never>
// ▼
const _program1 = errors.pipe(Effect.orElse(() => Effect.succeed("Handled")))
class MyError extends Error {}
// orElseFail:把任何期望錯誤映射成單一新錯誤
// ┌─── Effect.Effect<string, MyError, never>
// ▼
const _program2 = errors.pipe(Effect.orElseFail(() => new MyError()))
// mapError:轉換錯誤(仍是失敗),保留失敗語義
// ┌─── Effect.Effect<string, Error, never>
// ▼
const _program3 = errors.pipe(
Effect.mapError((oldErr) => new Error(`error: ${String(oldErr)}`))
)
// match:把成功/失敗折疊成一個純值
// ┌─── Effect.Effect<string, never, never>
// ▼
const _program4 = errors.pipe(
Effect.match({
onSuccess: (x) => `success: ${x}`,
onFailure: (e) => `handled error: ${e}`
})
)
// matchEffect:像 match,但回傳 Effect,兩側更有彈性
// ┌─── Effect.Effect<string, never, never>
// ▼
const _program5 = errors.pipe(
Effect.matchEffect({
onSuccess: (x) => Effect.succeed(`success: ${x}`),
onFailure: (e) => Effect.succeed(`handled error: ${e}`)
})
)
// firstSuccessOf:多個候選 Effect,取第一個成功者,如果全部失敗會回傳最後一個失敗結果
// ┌─── Effect.Effect<string, Error, never>
// ▼
const _program6 = Effect.firstSuccessOf([
Effect.fail(new Error("fail")),
Effect.succeed("success")
])
到目前為止,我們看到的這些 combinators 都是在 pipe 風格中使用。那麼在 generator 環境中如何處理錯誤呢?其中一個選項是根本不處理它們,讓它們向上傳播。在這種情況下,你的 generator 基本上只代表「快樂」的、無錯誤的路徑(happy path)。你可以使用 .pipe
和 combinators 在 generator 之後處理錯誤。
先準備一個「可能失敗」的 Effect:
const mightFail = Effect.sync(() => Math.random()).pipe(
Effect.flatMap((r) => r > 0.5 ? Effect.fail(new Error("fail")) : Effect.succeed(r))
)
Effect.runSync(mightFail)
策略 A:讓錯誤向外冒泡,在外層處理(最清晰)
const handledGen1 = Effect.gen(function*() {
const r = yield* Effect.sync(() => Math.random())
if (r > 0.5) {
return yield* Effect.fail(new Error("fail"))
}
return yield* Effect.succeed(r * 2)
}).pipe(Effect.catchAll(() => Effect.succeed(-1)))
Effect.runPromise(handledGen1).then((n) => console.log("A:", n))
策略 B:在 adapter pipeline 就地處理(就近降級)
const mightFail = Effect.sync(() => Math.random()).pipe(
Effect.flatMap((r) => r > 0.5 ? Effect.fail(new Error("fail")) : Effect.succeed(r))
)
const handledGen2 = Effect.gen(function*() {
const r = yield* pipe(mightFail, Effect.catchAll(() => Effect.succeed(-1)))
return r * 2
})
Effect.runPromise(handledGen2).then((n) => console.log("B:", n))
策略 C:把錯誤變成值來運算:Effect.either
語意提醒:Either.Right = 成功
,Either.Left = 失敗
const mightFail = Effect.sync(() => Math.random()).pipe(
Effect.flatMap((r) => r > 0.5 ? Effect.fail(new Error("fail")) : Effect.succeed(r))
)
const handledGen3 = Effect.gen(function*() {
const either = yield* Effect.either(mightFail)
if (Either.isRight(either)) {
return either.right * 2
} else {
console.error("C error:", either.left.message)
return -1
}
})
Effect.runPromise(handledGen3).then((n) => console.log("C:", n))
如果你現在覺得這個架構太複雜,完全可以先跳過這部分。
這段程式碼展示了一個完整的分層錯誤處理架構,從底層的資料來源到上層的用戶介面,每個層級都有明確的錯誤處理責任。
程式碼定義了三種具語意的錯誤類型:
資料層:負責將原始錯誤轉換為網域錯誤
業務層:組合多個資料來源,讓錯誤自然向上傳播
表現層:將所有錯誤收斂為統一的用戶友好格式
使用 catchTags 精確處理每種錯誤類型,TypeScript 會強制你處理所有可能的錯誤,避免遺漏任何錯誤情況。
// ============================================================================
// 1. 定義網域錯誤類型 (Domain Error Types)
// ============================================================================
// 這些是我們應用程式中可能發生的錯誤,每個都有明確的語義
/** 缺少必要的環境變數設定 */
class MissingConfigError {
readonly _tag = "MissingConfigError"
constructor(readonly key: string) {}
}
/** HTTP 請求失敗 */
class HttpError {
readonly _tag = "HttpError"
constructor(readonly status: number, readonly url: string) {}
}
/** JSON 解析失敗 */
class ParseError {
readonly _tag = "ParseError"
constructor(readonly reason: string) {}
}
// ============================================================================
// 2. 低層資料來源 (Data Sources)
// ============================================================================
// 這些函數負責從外部系統獲取資料,並將可能的錯誤轉換成我們的網域錯誤
/**
* 從環境變數中獲取設定值
* @param key 環境變數的鍵名
* @returns Effect 成功時返回設定值,失敗時返回 MissingConfigError
*/
function getConfig(key: string) {
return Effect.sync(() => process.env[key]).pipe(
Effect.flatMap((value) =>
value
? Effect.succeed(value)
: Effect.fail(new MissingConfigError(key))
)
)
}
/**
* 模擬 API 回應 - 用於測試不同情況
* @param url 請求的 URL
* @returns 模擬的 API 回應資料
*/
function mockApiResponse(url: string) {
if (url.includes("/users")) {
return {
users: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" }
]
}
}
if (url.includes("/error")) {
throw new HttpError(500, url)
}
if (url.includes("/invalid")) {
return "invalid json" // 故意返回無效的 JSON
}
return { data: "success" }
}
/**
* 發送 HTTP 請求並解析 JSON 回應
* @param url 請求的 URL
* @returns Effect 成功時返回解析後的資料,失敗時返回 HttpError 或 ParseError
*/
function fetchJson(url: string) {
return Effect.tryPromise({
try: async () => {
// 模擬網路延遲
await new Promise((resolve) => setTimeout(resolve, 100))
// 模擬 API 回應
const data = mockApiResponse(url)
// 檢查是否為無效的 JSON 格式
if (typeof data === "string") {
throw new ParseError("Invalid JSON format")
}
return data as unknown
},
catch: (error) => {
// 如果是我們定義的網域錯誤,直接返回
if (error instanceof HttpError || error instanceof ParseError) {
return error
}
// 其他錯誤轉換為標準 Error
return new Error(String(error))
}
}).pipe(
// 將非網域錯誤轉換為網域錯誤
Effect.mapError((error) => {
if (error instanceof HttpError || error instanceof ParseError) {
return error
}
return new ParseError(error.message ?? "Unknown parse error")
})
)
}
// ============================================================================
// 3. 應用層業務邏輯 (Application Layer)
// ============================================================================
// 組合低層函數來實現業務需求
/**
* 載入用戶列表的業務邏輯
* 型別:Effect<never, MissingConfigError | HttpError | ParseError, User[]>
*/
const loadUsers = Effect.gen(function*() {
// 1. 獲取 API 基礎 URL
const baseUrl = yield* getConfig("API_BASE") // 可能失敗:MissingConfigError
// 2. 發送請求獲取用戶資料
const responseData = yield* fetchJson(`${baseUrl}/users`) // 可能失敗:HttpError | ParseError
// 3. 提取用戶列表
const users = (responseData as any).users as Array<{ id: number; name: string }>
return users
})
// ============================================================================
// 4. 表現層錯誤處理 (Presentation Layer)
// ============================================================================
// 將業務錯誤轉換為用戶友好的訊息
/**
* 處理載入用戶的結果,將所有可能的錯誤轉換為統一的回應格式
* 型別:Effect<never, never, { ok: true; users: User[] } | { ok: false; message: string }>
*/
const programUsers = loadUsers.pipe(
// 使用 catchTags 精確處理每種錯誤類型
Effect.catchTags({
MissingConfigError: (error) =>
Effect.succeed({
ok: false,
message: `缺少設定:${error.key}`
}),
HttpError: (error) =>
Effect.succeed({
ok: false,
message: `API 錯誤:${error.status}`
}),
ParseError: (error) =>
Effect.succeed({
ok: false,
message: error.reason
})
}),
// 將成功結果包裝成統一的格式
Effect.map((result) =>
Array.isArray(result)
? { ok: true, users: result }
: result
)
)
// ============================================================================
// 5. 測試與示範 (Testing & Demonstration)
// ============================================================================
// 展示不同錯誤情況的處理方式
/**
* 統一的錯誤處理函數,用於所有測試
* @param result 處理結果
* @param testName 測試名稱
*/
function handleTestResult(result: any, testName: string) {
if (result.ok && "users" in result) {
console.log(`✅ ${testName} - 載入成功,用戶數:${result.users.length}`)
console.log(" 用戶列表:", result.users)
} else if ("message" in result) {
console.log(`❌ ${testName} - 載入失敗:${result.message}`)
}
}
/**
* 創建一個會觸發特定錯誤的載入用戶函數
* @param endpoint API 端點
* @returns 處理後的 Effect
*/
function createLoadUsersWithEndpoint(endpoint: string) {
const loadUsersWithEndpoint = Effect.gen(function*() {
const baseUrl = yield* getConfig("API_BASE")
const data = yield* fetchJson(`${baseUrl}${endpoint}`)
const users = (data as any).users as Array<{ id: number; name: string }>
return users
})
return loadUsersWithEndpoint.pipe(
Effect.catchAll((error) => {
if (error instanceof MissingConfigError) {
return Effect.succeed({ ok: false, message: `缺少設定:${error.key}` })
}
if (error instanceof HttpError) {
return Effect.succeed({ ok: false, message: `API 錯誤:${error.status}` })
}
if (error instanceof ParseError) {
return Effect.succeed({ ok: false, message: error.reason })
}
return Effect.succeed({ ok: false, message: `未處理的錯誤:${String(error)}` })
}),
Effect.map((result) => (Array.isArray(result) ? { ok: true, users: result } : result))
)
}
/**
* 執行所有測試案例
*/
async function runErrorHandlingTests() {
console.log("🚀 開始執行錯誤處理測試...\n")
// 測試 1: 正常情況 - 成功載入用戶
console.log("=== 測試 1: 正常情況 ===")
process.env.API_BASE = "https://api.example.com"
const result1 = await Effect.runPromise(programUsers)
handleTestResult(result1, "正常情況")
// 測試 2: 缺少環境變數 - 觸發 MissingConfigError
console.log("\n=== 測試 2: 缺少環境變數 ===")
delete process.env.API_BASE
const result2 = await Effect.runPromise(programUsers)
handleTestResult(result2, "缺少環境變數")
// 測試 3: HTTP 錯誤 - 觸發 HttpError
console.log("\n=== 測試 3: HTTP 錯誤 ===")
process.env.API_BASE = "https://api.example.com"
const result3 = await Effect.runPromise(createLoadUsersWithEndpoint("/error"))
handleTestResult(result3, "HTTP 錯誤")
// 測試 4: JSON 解析錯誤 - 觸發 ParseError
console.log("\n=== 測試 4: JSON 解析錯誤 ===")
const result4 = await Effect.runPromise(createLoadUsersWithEndpoint("/invalid"))
handleTestResult(result4, "JSON 解析錯誤")
console.log("\n✨ 所有測試完成!")
}
// 執行測試
runErrorHandlingTests().catch(console.error)
Effect 的錯誤處理確實比傳統的 try-catch 複雜一些,但它的價值在於: