本篇要來講一個新的概念錯誤累積(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.all
和 Effect.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 並累積成功和失敗的結果。
特色:
// 驗證字串是否為整數格式(可選正負號 + 數字)
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.forEach
函數類似。但它會在錯誤通道中收集所有錯誤。
Effect.validateAll
和 Effect.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
處理一個可迭代對象,並對每個元素套用一個 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.partition
和Effect.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
Effect.zip
、Effect.forEach
、Effect.all
預設採用此策略,遇到第一個錯誤就停止執行Effect.validate
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'
}
}
}
Effect.validateAll
Effect.validateFirst
Effect.partition
[失敗清單, 成功清單]
的 tuple 格式