iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Modern Web

用 Effect 實現產品級軟體系列 第 7

[學習 Effect Day7] 了解 Effect Type 並創建 Effect

  • 分享至 

  • xImage
  •  

Effect 的核心目標,是把「副作用的描述」與「副作用的執行」徹底分離,讓我們能以型別安全、可組合的方式建構複雜流程。你可以把 Effect 想成「一份待執行的計畫」,而不是「立刻執行的動作」。並且計劃一但建立就不可改變(immutable)。

Effect Type

先用下圖認識 Effect 的三個型別參數(Success、Error、Requirements)。
effect-type

先強調一件事:Effect 不是 function 本身。它是一種可描述同步/非同步/可併發、且具備資源生命週期的計算模型。真正的執行會交由 runtime 負責。因此我們可以先用純函式方式把流程「組裝好」,再交給 runtime 安全地執行。

我們相來講講三個型別參數的意義

  1. Success(A):成功時的回傳型別
  • 代表這個 Effect 成功執行後會給你的值。
  • 如果是 void:表示「成功但沒有有用資訊」,例如只做了某個動作。
  • 如果是 never:表示它永遠不會成功結束(例如無限迴圈、或只會失敗),也可用來表達「會一直跑下去直到被取消」。
  1. Error(E):失敗時的錯誤型別
  • 代表可預期的錯誤集合(可用 union 精準建模)。
  • 如果是 never:表示「不會失敗」。⚠️ 注意:仍有可能發生 defect(非預期拋錯)。
  1. Requirements(R):執行所需的環境依賴
  • 代表執行這個 Effect 需要哪些服務(例如 Config、Logger、DbClient)。
  • 這些依賴會被放在 Context 這個集合裡,執行時由 runtime 提供。
  • 如果是 never:表示「沒有依賴」,Context 是空的,可在任何地方執行。

幾個常見的型別形狀:

  • Effect<A, never, never>:只會成功、沒有依賴。常見於純計算或已知會成功的常數。
  • Effect<never, E, R>:只會失敗(或永不成功),且需要依賴。常用於描述「失敗分支」或中止流程。
  • Effect<void, never, R>:只做事、不回傳值、也不會失敗,但需要某些環境(例如記錄 log)。
  • Effect<A, E, R>:一般情況:可能成功回傳 A、也可能失敗為 E,並需要 R 作為上下文。

更白話地說:

  • Success:成功時回什麼?沒有就用 void;若設計上永不成功就用 never。
  • Error:可能失敗嗎?錯誤長什麼型別?完全不會失敗就用 never。
  • Requirements:需要哪些服務?需要就放到 R;完全不需要就用 never。

理解了 Effect Type 的語意之後,我們就能開始「創建」 Effect。

創建 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.succeedEffect.fail

succeed 和 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> { /* ... */ }
  • 成功型別 A 是 number:成功除法的結果。
  • 失敗型別 E 是 Error:當 b === 0 時的可預期錯誤。
  • 需求型別 R 是 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.syncEffect.try。先看看 Effect.sync

sync 和 try

使用 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。

promise 和 tryPromise

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 來包裝:

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

參考資料


上一篇
[學習 Effect Day6] 建立 Effect 專案 (二)
下一篇
[學習 Effect Day8] 執行 Effect
系列文
用 Effect 實現產品級軟體10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言