Effect
的核心目標,是把「副作用的描述」與「副作用的執行」徹底分離,讓我們能以型別安全、可組合的方式建構複雜流程。你可以把 Effect 想成「一份待執行的計畫」,而不是「立刻執行的動作」。並且計劃一但建立就不可改變(immutable)。
先用下圖認識 Effect
的三個型別參數(Success、Error、Requirements)。
先強調一件事:Effect 不是 function 本身。它是一種可描述同步/非同步/可併發、且具備資源生命週期的計算模型。真正的執行會交由 runtime 負責。因此我們可以先用純函式方式把流程「組裝好」,再交給 runtime 安全地執行。
我們相來講講三個型別參數的意義
幾個常見的型別形狀:
Effect<A, never, never>
:只會成功、沒有依賴。常見於純計算或已知會成功的常數。Effect<never, E, R>
:只會失敗(或永不成功),且需要依賴。常用於描述「失敗分支」或中止流程。Effect<void, never, R>
:只做事、不回傳值、也不會失敗,但需要某些環境(例如記錄 log)。Effect<A, E, R>
:一般情況:可能成功回傳 A、也可能失敗為 E,並需要 R 作為上下文。更白話地說:
理解了 Effect Type 的語意之後,我們就能開始「創建」 Effect。
先從簡單的例子開始:撰寫一個除法 function,當除數為 0 時拋出錯誤。
// ┌─── number
// ▼
function divide(a: number, b: number) {
if (b === 0) throw new Error("Cannot divide by zero")
// ┌─── number
// ▼
const result = a / b
return result
}
上面這段程式碼中,參數 (a, b)
的型別都是 number。若在編輯器中 hover result
,TypeScript 會推論它是 number。然而可以看出:這個 function 既可能正常回傳計算結果,也可能拋出錯誤;但這個錯誤在型別系統中是「隱性」的,無法被靜態檢查。為了把錯誤顯性化,我們引入兩個建構函式(constructors):Effect.succeed
與 Effect.fail
。
import { Effect } from "effect"
// ┌─── Effect.Effect<never, Error, never> | Effect.Effect<number, never, never>
// ▼
function divideEff(a: number, b: number) {
if (b === 0) {
// ┌─── Effect.Effect<never, Error, never>
// ▼
const error = Effect.fail(new Error("Cannot divide by zero"))
return error
}
// ┌─── Effect.Effect<number, never, never>
// ▼
const success = Effect.succeed(a / b)
return success
}
從上面的程式碼可以看到:
Effect.fail
建立「只會失敗」的分支。Effect.succeed
建立「只會成功」的分支。const error: Effect.Effect<never, Error, never> = Effect.fail(new Error("Cannot divide by zero"))
const success: Effect.Effect<number, never, never> = Effect.succeed(a / b)
因為 function 的各個 return
分支需要相容,TypeScript 會自動把 divideEff
的回傳型別自動推論為兩者的「合併」:
function divideEff(a: number, b: number): Effect.Effect<number, Error, never> { /* ... */ }
number
:成功除法的結果。Error
:當 b === 0
時的可預期錯誤。never
:表示這個計算不依賴任何外部環境。相較於原本直接 throw
的版本,使用 Effect 讓錯誤從「隱性」轉為「顯性」。這能讓開發者更清楚掌握 function 的所有輸出型別,提升可讀性與可維護性。
不過要小心同步的「急切求值」。Effect.succeed(x)
只是把「已經存在」的值 x
包裝成成功的 Effect;若把 Math.random()
直接當作引數傳入,它會在建立表達式的當下就被求值,導致值被「固定」。這與我們期望在「執行時」才產生值的行為不同。看個例子:
// ┌─── 0.123456...
// ▼
const randomEff = Effect.succeed(Math.random())
// 我知道 Effect.runSync 還沒講到😣,你就先視為它是一個將 Effect 作為參數的 function 就好。
console.log(Effect.runSync(randomEff)) // 與前面的 Math.random() 結果相同(被固定住了)
為了延後計算,我們引入一個常見概念:thunk。thunk 是「不接受任何參數、回傳某個值」的 function,常用來延後同步副作用的執行。前端開發者最熟悉的例子大概是:
<button onClick={() => console.log("啊~我被點了☺️")}>Click me</button>
Effect 提供了兩個與 thunk 搭配使用的建構函式,能延後同步副作用的執行:Effect.sync
與 Effect.try
。先看看 Effect.sync
。
使用 Effect.sync
搭配 thunk,就能把同步副作用延後到「執行時」才發生。程式碼如下:
function drawRandom() {
return Effect.sync(() => Math.random())
}
// ┌─── Effect.Effect<number, never, never>
// ▼
const program = drawRandom()
上面把「生成隨機數」這個同步副作用封裝成一個 Effect,讓副作用的定義與啟動時機解耦。如此能在合適的時刻才真正執行,提高可預測性與可控性。不過如果 Effect.sync
中真的拋出了錯誤,那代表出現了非預期的錯誤,我們稱為缺陷(defect)。它不在 Error channel 中,會直接終結你的程式,因為你既沒預料到、也沒有處理機制。來看個例子:
// ┌─── Effect.Effect<never, never, never>
// ▼
const NEVER = Effect.sync(() => {
throw new Error("will cause a defect");
});
可以看到 Error channel 的型別是 never。但我們明明知道會「拋錯」。該怎麼辦?此時就應改用 Effect.try
:它會自動捕捉同步拋出的錯誤,並把型別推導到 Error channel。範例如下:
// ┌─── Effect.Effect<never, UnknownException, never>
// ▼
const program = Effect.try(() => {
throw new Error("effect will automatically catch the error");
});
可以看到 Error channel 的型別是 UnknownException
。但這個型別過於寬鬆——我們無法得知錯誤的具體形狀。因此 Effect.try
提供了 function overload 的寫法,讓我們自行定義錯誤型別:
class JsonParseError{}
// ┌─── Effect.Effect<any, JsonParseError, never>
// ▼
const program = Effect.try({
try: () => JSON.parse("invalid json"),
catch: (_unknownError) => new JsonParseError("json parse error")
})
這樣一來,Error channel 就會是我們自定義的 JsonParseError
,錯誤型別更加明確。是不是跟 try/catch 的寫法很像?😂
try {
return JSON.parse("invalid json")
} catch (_unknownError) {
throw new JsonParseError("json parse error")
}
好欸~👯♀️ 同步(sync)的創建方式講完了,接著看看非同步(async)。一般來說非同步會回傳 Promise,但在大型流程中處理 Promise 的錯誤常讓程式變得零散。因為你通常得在「每個步驟」各自 try/catch,讓錯誤處理散落在各處、風格不一致、難以追蹤與維護。不過 Effect 當然提供了相應的解法。我們先從 Effect.promise
開始,看看如何把「不會被 reject 的 Promise」轉為 Effect。
function wait(ms: number): Promise<string> {
return new Promise((resolve) => setTimeout(() => resolve("resolved!"), ms))
}
// ┌─── Effect.Effect<string, never, never>
// ▼
const program = Effect.promise(() => wait(1000))
Effect.runPromise(program)
從上面程式碼可以看出,program
的型別是 Effect.Effect<string, never, never>
。也就是說:成功時會回傳「字串本身」,而不是「尚待 resolve 的 Promise<string>
」。
補充一下:Promise 本身是泛型,無法像 function 那樣從 return 值反推輸出型別,因此最好顯式標註。若不標註,預設常會變成 Promise<unknown>
,進而影響 Effect 的型別推論。若非同步可能失敗,應改用 Effect.tryPromise
,讓我們看看實際的例子🌰。:
// ┌─── Effect.Effect<Response, UnknownException, never>
// ▼
function getTodo(id: number) {
// Will catch any errors and propagate them as UnknownException
return Effect.tryPromise(() => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`))
}
// ┌─── Effect<Response, UnknownException, never>
// ▼
const program = getTodo(1)
和 Effect.try
相同,這裡的 Error channel 也會先推導為 UnknownException
。最佳實踐是使用 overload 形式,將未知錯誤 remap 成我們定義過的錯誤型別:
class FetchError extends Error {}
// ┌─── Effect.Effect<Response, FetchError, never>
// ▼
function getTodo(id: number) {
return Effect.tryPromise({
try: () => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`),
catch: (unknown) => new FetchError(`something went wrong: ${String(unknown)}`)
})
}
// ┌─── Effect.Effect<Response, FetchError, never>
// ▼
const program = getTodo(1)
不過,並非所有非同步運算都以 Promise 形式存在。在 Node.js 中,像 fs.readFile(path, options, callback)
、writeFile(path, data, callback)
、stat(path, callback)
等 API 採用 callback 風格。這類情境可以用 Effect.async
來包裝:
import * as NodeFS from "node:fs"
// ┌─── Effect.Effect<Buffer<ArrayBufferLike>, Error, never>
// ▼
function readFile(filename: string) {
return Effect.async<Buffer, Error>((resume) => {
NodeFS.readFile(filename, (error, data) => {
if (error) {
resume(Effect.fail(error))
} else {
resume(Effect.succeed(data))
}
})
})
}
// ┌─── Effect.Effect<Buffer<ArrayBufferLike>, Error, never>
// ▼
const program = readFile("package.json")
上面的範例中,我們手動標註了 Effect.async
的型別,因為 TypeScript 無法從 callback 的內容推斷出正確的回傳型別。Effect.async
內部會提供一個 resume
函式作為參數,它的語意是「告訴 Effect:非同步工作完成了,這是結果」。概念上很像 Promise 的 resolve
。注意 resume
在同一個 Effect.async
中只能被呼叫一次,若重複呼叫,除了第一次以外都會被忽略喔~
本文說明了:Effect
是一個「可組合的計畫」,而非立即執行的程式碼。我們拆解了三個型別參數 Success
/ Error
/ Requirements
的語意,讓成功、錯誤與執行所需的環境成為型別的一部分,進而提升可讀性與可維護性。
同時也介紹了七種常用的建構函式(constructors)。底下表格整理了各自的使用時機,供讀者參考:
類別 | 使用方法 | 說明 |
---|---|---|
值 (value) | succeed / fail | 用來處理成功或失敗的值 |
同步函式 (sync function) | sync / try | 用來處理同步函式執行 |
非同步函式 (async function) | promise / tryPromise | 用來處理非同步 Promise |
非同步回呼 (async callback) | async | 用來處理非同步 callback |