iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Modern Web

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

[學習 Effect Day19] Effect 進階錯誤管理 (五)

  • 分享至 

  • xImage
  •  

本篇要來講一個新的概念錯誤累積(Error Accumulation),它指的是:一次性的執行就把「所有錯誤」都收集回來,而不是遇到第一個錯誤就停止(Fail‑Fast)。這對表單驗證、批次處理、匯入資料特別重要,因為有些情景使用者需要一次看到「完整錯誤清單」。

舉例來說,如果我今天有一個註冊表單的功能,我在做後端驗證時,通常有多欄位(username、email、password、tags),每欄位又可能有多條規則(必填、格式、長度…)。Fail‑Fast 只會回第一個出錯欄位的錯誤,使用者往往得「修一個再送一次」來看到下一個錯誤,體驗不好。改用錯誤累積,一次把所有欄位的所有錯誤回傳,修一次就好。(我知道可以用 zod 做前後端的表單驗證,也做得到一次性驗證所有欄位,但我們在學習 Effect 麻~🙂‍↕️)

在正式介紹錯誤累積之前,我們得先介紹一下一些常見的 序列組合子(sequential combinator)。它們是幫我們串接多個 Effect 的工具,我們來看一下例子🌰。

Effect.zip

Effect.zip 會將兩個 Effect 組合成一個,並回傳一個包含兩個結果的 tuple。兩個 Effect 都必須成功,整個操作才會成功。如果其中任何一個失敗,整個操作就會失敗。來看一下例子🌰。

import { Effect } from "effect"

const mustBeMinLen = (s: string, min: number) => s.length >= min ? Effect.succeed(s) : Effect.fail(`length < ${min}`)

const mustInclude = (s: string, token: string) =>
  s.includes(token) ? Effect.succeed(s) : Effect.fail(`missing "${token}"`)

const program = Effect.zip(
  mustBeMinLen("ab", 3), // 失敗
  mustInclude("foobar", "@") // 不會執行(因為左側先失敗)
)

Effect.runSync(
  Effect.match(program, {
    onSuccess: ([a, b]) => console.log("OK:", a, b),
    onFailure: (err) => console.log("ERROR:", err)
  })
)
// 輸出:
// ERROR: length < 3

從上面的例子可以看到,兩個規則驗證結果其實都是失敗的,但因為是 Fail‑Fast,所以只會回第一個失敗的錯誤。

Effect.forEach

Effect.forEach 會對陣列中的每個元素執行指定的函數,並回傳一個帶有結果陣列的Effect。但如果任何一個元素執行失敗,就會立即停止並回傳錯誤。來看一下例子🌰。

const mustBePositive = (n: number, index: number) =>
  n > 0 ? Effect.succeed(n) : Effect.fail(`not positive: ${n} at index ${index}`)

const numbers = [2, -1, -2, 3]

const program = Effect.forEach(numbers, (n, index) => mustBePositive(n, index))

Effect.runSync(
  Effect.match(program, {
    onSuccess: (values) => console.log("OK:", values),
    onFailure: (err) => console.log("ERROR:", err)
  })
)
// 輸出:
// ERROR: not positive: -1 at index 1

從上面的例子可以看到,array 迭代到第二個元素時就失敗了,所以整個操作就停止了,並回傳當下的錯誤。

Effect.all

Effect.all 會依序執行多個 Effect,並回傳一個包含所有結果的 tuple。tuple 中的結果順序與傳入 Effect.all 的 Effect 順序相同。但如果其中任何一個失敗,整個操作就會失敗。來看一下例子🌰。

  const getData = (id: number) => id > 1 ? Effect.fail(`Data ${id}`) : Effect.succeed(`Data ${id}`)

  const program = Effect.all([
    getData(1),
    getData(2),
    getData(3)
  ])

  Effect.runSync(
    Effect.match(program, {
      onSuccess: (results) => console.log("OK:", results),
      onFailure: (err) => console.log("ERROR:", err)
    })
  )
// 輸出:
// ERROR: Data 2

❖ 補充:Effect.allEffect.forEach 的區別:

  • 輸入格式不同:
    • Effect.all 接受一個 Effect 陣列:Effect.all([effect1, effect2, effect3])
    • Effect.forEach 接受一個資料陣列和一個函數:Effect.forEach([data1, data2, data3], fn)
  • 使用場景不同:
    • Effect.all 適合當你已經有多個獨立的 Effect 要執行,並回傳一個包含所有結果的 tuple。
    • Effect.forEach 適合當你有一堆資料,要對每個資料套用同一個函數,並回傳一個包含所有結果的陣列。
  • 相同之處:
    • 回傳結果都會依照原資料的順序

然而,在某些情況下,您可能希望收集所有錯誤,而不是快速失敗。在這種情況下Effect.validate就能派上用場。它會把所有錯誤一次收齊,回傳錯誤清單。來看一下例子🌰。

Effect.validate

功能:組合多個 Effect 並累積成功和失敗的結果。

特色

  • 即使其中一些 Effect 失敗,也會繼續執行所有 Effect
  • 與其他函數不同,不會在遇到錯誤時停止執行
  • 將所有錯誤收集到一個 Cause 中,這也代表需要搭配 run*Exit 的語法來執行,才能拿到所有錯誤結果。
  • 最終結果包含所有成功結果和累積的失敗
// 驗證字串是否為整數格式(可選正負號 + 數字)
const isIntegerString = (s: string) => /^[+-]?\d+$/.test(s) ? Effect.succeed(s) : Effect.fail(`not int: "${s}"`)

// 驗證數字是否在指定範圍內
const isWithinRange = (n: number, min: number, max: number) =>
  n >= min && n <= max
    ? Effect.succeed(n)
    : Effect.fail(`out of range [${min}, ${max}]: ${n}`)

// 將字串解析為整數,先驗證格式再轉換
const parseToInt = (s: string) => Effect.flatMap(isIntegerString(s), (ok) => Effect.succeed(parseInt(ok, 10)))

// 使用 Effect.validate 來收集所有驗證錯誤
// 這會執行所有驗證並收集所有失敗的錯誤,而不是在第一個錯誤時就停止
const task1 = Effect.flatMap(parseToInt("42"), (n) => isWithinRange(n, 0, 100)) // 成功
const task2 = parseToInt("abc") // 失敗:不是整數
const task3 = Effect.flatMap(parseToInt("150"), (n) => isWithinRange(n, 0, 100)) // 失敗:超出範圍

const program = task1.pipe(
  Effect.validate(task2),
  Effect.validate(task3)
)

// 執行 Effect 並處理結果
Effect.runPromiseExit(program).then(console.log)
// 輸出:
// {
//   _id: 'Exit',
//   _tag: 'Failure',
//   cause: {
//     _id: 'Cause',
//     _tag: 'Sequential',
//     left: { _id: 'Cause', _tag: 'Fail', failure: 'not int: "abc"' },
//     right: {
//       _id: 'Cause',
//       _tag: 'Fail',
//       failure: 'out of range [0, 100]: 150'
//     }
//   }
// }

Effect.validateAll

Effect.validateAll 函數與 Effect.forEach 函數類似。但它會在錯誤通道中收集所有錯誤。

Effect.validateAllEffect.forEach 都是用來處理集合中每個元素的函數。主要差異在於錯誤處理方式:

  • Effect.forEach 遇到錯誤時會停止處理
  • Effect.validateAll 會繼續處理所有元素,並將所有錯誤收集起來
//      ┌─── Effect<number, string[], never>
//      ▼
const program = Effect.validateAll([1, 2, 3, 4, 5], (n) => {
  if (n < 4) {
    return Console.log(`item ${n}`).pipe(Effect.as(n))
  } else {
    return Effect.fail(`${n} is not less that 4`)
  }
})

// 執行 Effect 並處理結果
Effect.runPromiseExit(program).then(console.log)
// 輸出:
// item 1
// item 2
// item 3
// {
//   _id: 'Exit',
//   _tag: 'Failure',
//   cause: {
//     _id: 'Cause',
//     _tag: 'Fail',
//     failure: [ '4 is not less that 4', '5 is not less that 4' ]
//   }
// }

從輸出結果可以看到即便出錯,Effect.validateAll 還是會繼續處理所有元素,並將所有錯誤收集起來。
咦~成功結果的輸出呢?Effect.validateAll在錯誤發生時,是不會保留成功結果的喔!這點Effect.validate也是一樣。但別擔心,Effect 有提供 Effect.partition 方法來收集成功和失敗的所有結果。

Effect.partition

Effect.partition處理一個可迭代對象,並對每個元素套用一個 effectful 函數(必要條件)。它會傳回一個 tuple,其中第一部分包含所有失敗結果,第二部分包含所有成功操作。

//      ┌─── Effect<[string[], number[]], never, never>
//      ▼
const program = Effect.partition([0, 1, 2, 3, 4], (n) => {
  if (n % 2 === 0) {
    return Effect.succeed(n)
  } else {
    return Effect.fail(`${n} is not even`)
  }
})

Effect.runPromise(program).then(console.log, console.error)
// 輸出:
// [ [ '1 is not even', '3 is not even' ], [ 0, 2, 4 ] ]

從輸出可以看出Effect.partition是一個很強大的功能啊!這個方法在需要批次處理的任務下特別有用,可以一次拿到成功和失敗的所有結果。我個人先蒐藏起來了~😀

Effect.validateFirst

Effect.validateFirst方法會返回第一個成功的結果,或者如果所有操作都失敗則返回所有錯誤。這個函數跟Effect.partitionEffect.validateAll一樣,處理一個元素集合,並對每個元素應用 effectful 函數。

Effect.validateAll(會累積成功和失敗)不同,Effect.validateFirst 會停止並返回它遇到的第一個成功結果。如果沒有成功發生,它會返回所有累積的錯誤。所以當你對第一個成功結果感興趣,並希望一旦找到有效結果就避免進一步處理時,這會很有用。

//      ┌─── Effect<number, string[], never>
//      ▼
const program = Effect.validateFirst([1, 2, 3, 4, 5], (n) => {
  if (n < 4) {
    return Effect.fail(`${n} is not less that 4`)
  } else {
    return Console.log(`item ${n}`).pipe(Effect.as(n))
  }
})

Effect.runPromise(program).then(console.log, console.error)
// 輸出:
// item 4
// 4

總結

錯誤處理策略

  • Fail-Fast 模式Effect.zipEffect.forEachEffect.all 預設採用此策略,遇到第一個錯誤就停止執行
  • 錯誤累積模式:收集所有錯誤後一次性回傳,適合表單驗證、批次處理等場景

錯誤累積工具選擇指南

1. Effect.validate

  • 適用場景:處理 2-3 個獨立的 Effect
  • 注意事項:當錯誤任務超過 3 個時,會產生巢狀的 left 結構,建議改用 Effect.validateAll
  • 範例結構
    {
      _id: 'Exit',
      _tag: 'Failure',
      cause: {
        _id: 'Cause',
        _tag: 'Sequential',
        left: {
          _id: 'Cause',
          _tag: 'Sequential',
          left: [Object],
          right: [Object]
        },
        right: {
          _id: 'Cause',
          _tag: 'Fail',
          failure: 'out of range [0, 100]: 150'
        }
      }
    }
    

2. Effect.validateAll

  • 適用場景:陣列或物件的批次驗證
  • 優勢:避免巢狀結構,處理起來更簡潔

3. Effect.validateFirst

  • 適用場景:多策略驗證,取第一個成功結果
  • 特色:全失敗時回傳所有錯誤,成功時立即停止

4. Effect.partition

  • 適用場景:需要同時處理成功和失敗結果的批次任務
  • 回傳[失敗清單, 成功清單] 的 tuple 格式

參考資料


上一篇
[學習 Effect Day18] Effect 進階錯誤管理 (四)
下一篇
[學習 Effect Day20] Effect 服務管理(一)
系列文
用 Effect 實現產品級軟體22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言