iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Modern Web

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

[學習 Effect Day25] Effect 中的 Data Types(一)

  • 分享至 

  • xImage
  •  

Effect Type vs Data Type

  • Effect Type:一個「尚未執行的工作描述」。它可能需要外部環境才能跑,實際執行時要嘛成功回傳結果、要嘛以錯誤結束。在你真的啟動它之前,它不會做任何事也不會產生副作用。
  • Data Type:描述「長什麼樣、有哪些可能狀態」的純資料值(在 TS 中建立/操作即時且無副作用)。

為何需要 Data Types?

  • 明確語意
    • 用型別把可能狀態顯性化:Option(可能沒有值)、Either/Result(成功或帶型別的錯誤)、Exit(程序結果)、Cause(錯誤結構與脈絡)。
    • 好處:編譯期就能強迫你處理所有分支,減少執行期例外與 if-null 風險。
  • 邊界清晰、互通良好
    • 在純同步邏輯裡,用 Option/Either 等 Data Types 安全地建模資料與分支。
    • 當進入「需要副作用/非同步/資源/錯誤通道」的情境,再把這些資料型別嵌入到 Effect 的成功或錯誤通道中(例如 Effect<R, E, A> 的 E 或 A)。
    • 重點是分工:Data Types 負責描述狀態;Effect 負責描述執行流程與副作用。兩者組合,讓流程既可組合又可檢查。
  • 不可變、可比對與高品質集合
    • 使用不可變結構與結構性相等能避免共享可變狀態帶來的錯誤,利於快照/重試/快取。

接下來我們來介紹一些常用的 Data Types 吧~

data types 應用情境太多了。不過其實都很簡單,所以我們只會透過一些常見的例子來簡單介紹用法。如果你想要了解更多,可以參考官方文件

Option:更安全的「可能沒有值」

  • 用途
    • 用型別安全方式表達「也許沒有值」:Some/None,避免 null/undefined 地雷。
  • 適用情境
    • 讀取可選設定、環境變數、URL 查詢參數。
    • 查表或搜尋可能找不到的值。
    • 純同步邏輯中想避免 try/catch 與例外的控制流。

常見操作

import { Option } from "effect";

const USER_ID = null
// 透過可能为 null/undefined 的值建立 Option
const maybeUserId = Option.fromNullable(USER_ID)

// 用 match 模式比對
const greeting = Option.match(maybeUserId, {
  onSome: (id) => `Hi, ${id}`,
  onNone: () => "Guest"
})

const userIdOrDefault = Option.getOrElse(maybeUserId, () => "unknown")
  • 說明
    • Option.fromNullable(x): 把 null/undefined 轉成 Option,避免 if-null 判斷散落在程式中。
    • Option.match(maybe, { onSome, onNone }): 明確處理兩種情況(有值/無值),讓控制流更清楚。
    • Option.getOrElse(maybe, fallback): 真的需要「落地成值」時才提供預設值,平時維持 Option 以利後續組合。

依條件建立:liftPredicate

const isPositive = (n: number) => n > 0;

// 顯式用 some/none 建立
function parsePositiveExplicit(n: number): Option.Option<number> {
  return isPositive(n) ? Option.some(n) : Option.none()
}

// 用 liftPredicate 更簡潔:回傳 (b: number) => Option<number>
const parsePositive = Option.liftPredicate(isPositive)
  • 說明
    • liftPredicate(pred) 會回傳一個函式:給它一個值,若通過條件就回 Some(value),否則回 None
    • 等價於手寫 pred(x) ? Option.some(x) : Option.none(),但更精煉且可重用。
    • 適合用於輸入過濾、表單欄位驗證、URL 參數解析等情境,後續可直接用 map/flatMap/match 串接。

建模可選屬性(key 永遠存在,value 可選)

  • 鍵會一直存在(例如 email),但其值型別為 Option<string>,表示可能沒有值。
interface User = {
  readonly id: number;
  readonly username: string;
  readonly email: Option.Option<string>;
};

補充:選擇 Option 而非email?: string的好處

  • 鍵是否存在: Option<T> 下,key 固定存在(結構穩定);email? 可能連鍵都缺席。
  • 執行期語意: OptionSome/None 明確表示有值/無值,可被模式比對;email? 僅以鍵缺席或 undefined 表達,語意容易分散。
  • 可組合性: Option 具備 map/flatMap/match/getOrElse 等 API,能以表達式風格串接;email? 常需要零散的 if/?./?? 邏輯。
  • 型別強迫處理: Option 迫使呼叫端顯式處理 None,降低漏判空值的機率;email? 容易在流程中被忽略。
  • 內部資料模型 vs 對外介面: 內部使用 Option 讓結構穩定、語意清楚;對外輸出(API/JSON)再轉成可選鍵或 null

Either:具名錯誤或兩路分支

  • 用途

    • 同時攜帶 Left(常作錯誤)與 Right(成功)。比 Option 更能描述「為何失敗」。
  • 適用情境

    • 同步的輸入驗證、解析(parse)與轉換,需要保留錯誤訊息或結構。
    • 與非 Effect 區域互動,傳遞成功/失敗而不丟例外。
  • 說明

    • Either 適合作為「簡單可判別聯合」用於同步資料與錯誤建模;若要表達 Effect 的完整結果(成功、錯誤、缺陷、被中斷等),建議使用 Exit(官方建議)。參考:Either 官方文件

常見操作

import { Either } from "effect";

// 建立與基本使用
function parseIntegerEither(s: string) {
  const n = Number(s)
  if (!Number.isInteger(n)) {
    return Either.left("not an int")
  }
  return Either.right(n)
}

const rightValue = Either.right(1)
const leftValue = Either.left("oops")

// 右偏映射(成功路)
const mapped = Either.map(rightValue, (n) => n + 1)

// 鏈接(成功路)
const chained = Either.flatMap(parseIntegerEither("42"), (n) => Either.right(n + 1))

// 轉換錯誤(左路)
const normalizedErr = Either.mapLeft(parseIntegerEither("x"), (e) => ({ message: e }))

// 明確處理兩個分支
const rendered = Either.match(parseIntegerEither("foo"), {
  onRight: (n) => `ok: ${n}`,
  onLeft: (err) => `error: ${err}`
})

// 輸出:
// rightValue: { tag: 'Right', value: 1 }
// leftValue: { tag: 'Left', error: 'oops' }
// mapped: { tag: 'Right', value: 2 }
// chained: { tag: 'Right', value: 43 }
// normalizedErr: { tag: 'Left', error: { message: 'not an int' } }
// rendered: error: not an int
  • 說明
    • Either.right/left:建立成功/失敗。
    • Either.map / flatMap:右偏操作,僅在成功時變換或鏈接。
    • Either.mapLeft:只在失敗分支變換錯誤型別/結構。
    • Either.match:在一處完整處理兩個分支(成功/失敗)。

Exit:把執行結果資料化(測試、邊界、批次)

  • 用途
    • 描述 Effect 執行後的結果:Exit.Success<A>Exit.Failure<Cause<E>>。概念上近似 Either<A, Cause<E>>
  • 適用情境
    • 在邊界與批次流程集中蒐集「成功/失敗」而不丟例外或 Promise reject。
    • 測試/工作系統:一次跑多個 Effect,逐一分析、彙整結果。

常見操作

import { Effect, Exit, Cause } from "effect";

// 1) 同步執行並取得 Exit(成功)
const okExit = Effect.runSyncExit(Effect.succeed(42))

// 2) 同步執行並取得 Exit(失敗)
const errExit = Effect.runSyncExit(Effect.fail("my error"))

// 3) 匹配特定 Exit 並加以處理成功與失敗情境
//         ┌─── string
//         ▼
const renderedOk = Exit.match(okExit, {
  onSuccess: (value) => `Success: ${value}`,
  onFailure: (cause) => `Failure: ${Cause.pretty(cause)}`
})
//         ┌─── string
//         ▼
const renderedErr = Exit.match(errExit, {
  onSuccess: (value) => `Success: ${value}`,
  onFailure: (cause) => `Failure: ${Cause.pretty(cause)}`
})

// 4) 直接回傳 Exit(多用於測試或模擬)
//         ┌─── Exit<number, never>
//         ▼
const directSuccess = Exit.succeed(1)
//         ┌─── Exit<never, string>
//         ▼
const directFailure = Exit.failCause(Cause.fail("boom"))

// 輸出:
// renderedOk: Success: 42
// renderedErr: Failure: Error: my error
// directSuccess: { _id: 'Exit', _tag: 'Success', value: 1 }
// directFailure: {
//   _id: 'Exit',
//   _tag: 'Failure',
//   cause: { _id: 'Cause', _tag: 'Fail', failure: 'boom' }
// }
  • 說明
    • Effect.runSyncExit(effect): 同步執行並回傳 Exit;適合純同步流程或在測試/邊界處理結果。
    • Exit.match(exit, { onSuccess, onFailure }): 將兩種狀態一次處理;常配合 Cause.pretty 取得可讀字串。
    • Exit.succeed / Exit.failCause: 不經執行,直接建立結果值(常用於測試)。

Cause:失敗的結構化語言(Fail/Die/Interrupt/Sequential/Parallel)

  • 用途
    • Cause 以可組合的方式完整描述失敗:可區分預期錯誤(Fail)、缺陷(Die)、中斷(Interrupt),並保留錯誤在順序(Sequential)與並行(Parallel)發生時的關聯與脈絡。
  • 適用情境
    • 觀測與記錄:需要完整失敗脈絡(堆疊、並行多錯誤)以便 debug。
    • 低階基礎設施:重試、監控、錯誤整形、報表。

常見操作

import { Effect, Cause } from "effect";

// 1) 建立帶有不同 Cause 的 Effect
//    - Fail(可預期錯誤,會決定錯誤通道型別 E)
//    - Die(缺陷,不會影響 E,因此 error channel 為 never)
const asDie = Effect.failCause(Cause.die(new Error("Boom!"))) // Effect<never, never, never>
const asFail = Effect.failCause(Cause.fail("Oops")) // Effect<never, string, never>
// 2) 取得 Effect 的 Cause 並做處理(集中觀測)
const program = Effect.fail("error 1")

const allCauseEffect = Effect.catchAllCause(program, (cause) =>
  Effect.succeed({
    pretty: Cause.pretty(cause), // string
    failures: Cause.failures(cause), // Chunk<string>
    defects: Cause.defects(cause) // Chunk<unknown>
  }))

const allCause = Effect.runSync(allCauseEffect)
console.log("all causes:", allCause)
// 輸出:
// all causes: {
//   pretty: 'Error: error 1',
//   failures: { _id: 'Chunk', values: [ 'error 1' ] },
//   defects: { _id: 'Chunk', values: [] }
// }

延續上面程式碼,我們可以對 Cause 進行模式比對,來處理不同的失敗情境。

// 3) 實務:集中觀測 + 模式比對(邊界收斂)

// 範例程式:你可以替換成實際工作流程的 Effect
const program = Effect.fail("error 1")

const handled = Effect.catchAllCause(program, (cause) => {
  // 先「集中觀測」Cause 的全貌(供記錄或追蹤)
  const observed = { // 將 Cause 轉為易讀/可處理的摘要
    pretty: Cause.pretty(cause), // 轉為可讀多行字串,適合 console/log
    failures: Array.from(Cause.failures(cause)), // 可預期錯誤(由 fail 等)
    defects: Array.from(Cause.defects(cause)) // 非預期錯誤/缺陷(die 等)
  }

  // 再用「模式比對」把失敗分支化,收斂成邊界需要的結構
  const result = Cause.match(cause, {
    onFail: (e) => ({ status: 400, message: String(e) }),
    onDie: () => ({ status: 500, message: "Unexpected defect" }),
    onInterrupt: () => ({ status: 504, message: "Timeout" }),
    onEmpty: { status: 200, message: "OK" },
    onSequential: (l, r) => ({ status: 500, message: `seq: ${l.message} -> ${r.message}` }),
    onParallel: (l, r) => ({ status: 500, message: `par: ${l.message} | ${r.message}` })
  })

  return Effect.succeed({ observed, result })
})

console.log("集中觀測 + 模式比對:", Effect.runSync(handled))
// 輸出:
// 集中觀測 + 模式比對: {
//   observed: { 
//     pretty: 'Error: error 1',
//     failures: [ 'error 1' ], 
//     defects: [] 
//   },
//   result: { status: 400, message: 'error 1' }
// }
// 4) Empty:中性值與模式比對
import { Cause } from "effect";

// 建立 Empty(中性值),常用於合併的單位元或在測試中明確表示「沒有失敗」
const empty = Cause.empty

// 對 Empty 進行模式比對:會命中 onEmpty 分支
const label = Cause.match(empty, {
  onEmpty: "Empty",
  onFail: () => "Fail",
  onDie: () => "Die",
  onInterrupt: () => "Interrupt",
  onSequential: () => "Sequential",
  onParallel: () => "Parallel"
})
// 5) Interrupt:超時情境(避免意外重試)
import { Effect, Cause, Duration } from "effect";

// 模擬外部呼叫:需要 2 秒才會失敗
const callPayment = Effect.delay(Effect.fail("gateway overloaded"), Duration.seconds(2))

// 加上 1 秒超時(逾時會產生 Interrupt)
const withTimeout = Effect.timeout(callPayment, Duration.seconds(1))

// 只要是 Interrupt,我們回傳 504/不要重試;Fail 則可依業務決策重試
const InterruptHandled = Effect.catchAllCause(withTimeout, (cause) =>
  Effect.succeed(
    Cause.match(cause, {
      onInterrupt: () => ({ status: 504, message: "Payment Timeout" }),
      onFail: (e) => ({ status: 400, message: String(e) }),
      onDie: () => ({ status: 500, message: "Unexpected defect" }),
      onEmpty: { status: 200, message: "OK" },
      onSequential: (l, r) => ({ status: 500, message: `seq: ${l.message} -> ${r.message}` }),
      onParallel: (l, r) => ({ status: 500, message: `par: ${l.message} | ${r.message}` })
    })
  ))

Effect.runPromise(InterruptHandled).then((result) => {
  console.log("Interrupt Handled:", result)
})
// 輸出:
// Interrupt Handled: {
//   status: 400,
//   message: "TimeoutException: Operation timed out after '1s'"
// }
// 6) Parallel:並行批次(一次看見多個錯誤)
// 同時呼叫多個後端(可能同時失敗)
const userCall = Effect.fail("user: db unavailable")
const ordersCall = Effect.die(new Error("orders: decoder bug"))

const parallelCalls = Effect.all([userCall, ordersCall], { concurrency: 2 })

const report = Effect.catchAllCause(parallelCalls, (cause) =>
  Effect.succeed({
    failures: Array.from(Cause.failures(cause)),
    defects: Array.from(Cause.defects(cause))
  }))

Effect.runPromiseExit(report).then(console.log)
// 輸出:
// {
//   _id: 'Exit',
//   _tag: 'Success',
//   value: {
//     failures: [ 'user: db unavailable' ],
//     defects: [
//       Error: orders: decoder bug
//           at <anonymous> (/Users/eric/personal-project/effect-app/src/day25/Program.ts:181:33)
//           at ModuleJob.run (node:internal/modules/esm/module_job:345:25)
//           at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:651:26)
//           at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
//     ]
//   }
// }
// 7) Sequential:順序補救也失敗
// 先失敗 A,catch 後嘗試補救 B,但 B 也失敗 → Sequential(A -> B)
  const programSeq = Effect.failCause(
    Cause.fail("Oh no!") // A
  ).pipe(
    Effect.ensuring(Effect.failCause(Cause.die("Boom!"))) // B
  )
Effect.runPromiseExit(programSeq).then(console.log)
// 輸出:
// {
//   _id: 'Exit',
//   _tag: 'Failure',                                                        cause: {
//     _id: 'Cause',
//     _tag: 'Sequential',
//     left: { _id: 'Cause', _tag: 'Fail', failure: 'Oh no!' },
//     right: { _id: 'Cause', _tag: 'Die', defect: 'Boom!' }
//   }
// }

Cause:設計原則(Principles)

  • 明確區分失敗類型:以 Fail/Die/Interrupt 切分語意,避免「一種錯誤走天下」導致策略混淆(例如把逾時誤判為可重試)。
  • 保留組合脈絡:Sequential/Parallel 保留錯誤在流程中的結構,不壓平成單一訊息,讓除錯與追蹤具備可讀的「錯誤樹」。
  • 好觀測、好統計:錯誤被做成可組合的資料格式,可用 pretty / failures / defects / match 自動分類與彙整,方便做監控看板與報表。
  • 一致的抽象:從單一錯誤到並行多錯誤,皆以同一 Cause 抽象表示,跨域(批次、工作系統、微服務匯流)時仍可組合與比對。
  • 策略更安全:錯誤細節不會丟、資料結構是可比對的,因此可以在某處清楚訂出「要不要重試、何時告警、如何補償、對應哪個回應碼」,提升程式碼可讀性與可維護性。

Cause:語意速查表(Semantics Cheatsheet)

類型 定義 常見來源 建議策略
Empty 沒有任何失敗(空值) 合併多個結果時用的「空錯誤」 通常可忽略,無需處理
Interrupt Fiber 被中斷(cancel/timeout/scope 結束) 逾時、使用者取消、作用域結束 不重試;回應「已取消/逾時」;映射對應狀態碼/UI 文案
Sequential 先失敗 A,補救 B 又失敗(Sequential(A -> B) catch/ensuring 中再次失敗 保留脈絡;聚焦後一失敗的告警與補救;檢討補救流程
Parallel 並行分支同時失敗(Parallel(L | R) 並行工作、批次任務 彙整顯示/上報;逐一判定是否重試與補償

策略指引(Decision Guide)

  • 重新嘗試:只考慮「可恢復」的失敗來源;對 Interrupt 不重試,對缺陷(Die)改以修正程式為主。
  • 回應語意:Interrupt 對應「已取消/逾時」,Sequential/Parallel 保留結構以供觀測與報表(錯誤樹)。
  • 監控與告警:Sequential 著重「第二段失敗」;Parallel 需同時關注多個根因並做彙整告警。
  • 可讀性與除錯:保持 Cause 結構,不壓平成字串,讓跨服務的失敗能被比對與追蹤。

總結

本文說明 Data Types 與 Effect 的分工:前者以純資料建立可能狀態,後者描述執行與副作用。也依序介紹了 Option、Either、Exit、Cause 的用法與實際應用場景。但這些只是 Effect 中的一部分 Data Types,下一篇會繼續介紹剩下幾種常見的 Data Types。天啊~還真有點多😅。但沒關係,我們一起撐下去吧!

參考資料


上一篇
[學習 Effect Day24] Effect 服務管理(五)
下一篇
[學習 Effect Day26] Effect 中的 Data Types(二)
系列文
用 Effect 實現產品級軟體29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言