iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Modern Web

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

[學習 Effect Day10] 透過組裝 Effect 建構程式 (二)

  • 分享至 

  • xImage
  •  

上上篇(Day8)我們有講到盡可能 run Effect 在你的程式邊界(Edge),如果你整個程式都是由 Effect 組成,那大部分的時候你應該只會執行你的 Effect 程式一次。

這麼做的好處在於:

  • 單一執行點:一致套用逾時、重試、取消、回退;錯誤在邊界一次收斂。
  • 生命週期清楚:外部資源的取得、釋放與中斷集中管理。
  • 降低內部流程侵入性:核心邏輯不必知道重試/逾時/取消/日誌/追蹤,程式更單純好讀。
  • 降低隱性副作用:避免函式表面沒說卻做 I/O 或非決定性行為(讀/改全域、打 API、寫檔、印 log、Date.now()Math.random())。
  • 更好測試性:內部多半只是「描述」,替換環境與依賴後再 run 驗證即可。
  • 易於擴充:需要調整全域策略(如重試或逾時)只改邊界。

不過,當你必須與非 Effect 的程式或第三方 library 互動時,就會需要在程式執行流程中,取得部分結果傳遞給非 Effect 的程式,因此就需要執行多次 Effect 才能達到目的。

用 Effect pipe 組裝函式

到目前為止,我們所教過的 Effect 語法還相當受限:只能建立非常基礎的 Effect。但「真正的」程式遠比這複雜許多,它們通常涉及資料在流程中不斷轉換。

我們直接來看如何用 pipe 組裝不同的 function。展示一個具有完整資料流的程式。

import { pipe } from "effect";

const getDate = () => Date.now();
const double = (x: number) => x * 2;
const toString = (x: number) => x.toString();
const toUpperCase = (x: string) => x.toUpperCase();

const program = () => pipe(getDate(), double, toString, toUpperCase);

pipe 是一個函式,會接收一個值與一個 list of functions,然後依序把這些函式套用在該值上。它看起來幾乎和「物件方法鏈(Method chaining)」的寫法一樣,但資料不綁定在物件內。

需要注意的是,pipe 裡的每個 function 都必須只收一個參數,因為前一步的輸出會成為下一步的輸入;如果某個函式需要多個參數,就需要先做partial application。

❖ 補充:partial application 是什麼?

把一個需要多個參數的函式,先預填一部分參數,得到一個只需要剩下參數的新函式。這樣就能把多參數函式改造成 pipe 可用的「一元函數 (unary function)」。

function add(a: number, b: number): number {
  return a + b;
}

// 預填 a = 10,得到只需要 b 的一元函式
const add10 = add.bind(null, 10);

add10(5); // 15

❖ 補充: pipe 也可以直接接在 Effect type 後面使用

const after = Effect.succeed(5).pipe(
  Effect.map((x) => x * 2),
  Effect.map((x) => x.toString()),
);

使用 pipe 的好處

pipe 是一種以精簡、模組化方式組織應用程式並處理資料轉換的好方法,帶來以下好處:

  • 可讀性:以順序方式組合 function,資料流與每一步操作一目了然,更容易理解與維護。
  • 程式組織:可將複雜操作拆成小且好管理的 function,每個 function 只負責一件事,模組化更高、推理更容易。
  • 可重用性:把流程拆小後,function 能在不同 pipeline 或情境重用,提升重用性、降低重複。
  • 型別安全:善用型別系統在編譯期攔截錯誤。pipe 中 function 的輸入/輸出型別明確,確保資料正確流動並降低執行期錯誤。

不過我們真正要的是組裝 Effect,而不是組裝 function。這時就要介紹 Effect.map。我一起來看看它的語法吧~

map

import { Effect, pipe } from "effect"
//       ┌─── Effect.Effect<number, never, never>
//       ▼
const getDate = Effect.sync(() => Date.now())
//       ┌─── Effect.Effect<string, never, never>
//       ▼
const program = pipe(
  getDate, // Effect.Effect<number, never, never>
  Effect.map((x) => x * 2), // Effect.Effect<number, never, never>
  Effect.map((x) => x.toString()), // Effect.Effect<string, never, never>
  Effect.map((x) => x.toUpperCase()) // Effect.Effect<string, never, never>
)

可以透過上面這段程式碼看出,我們透過 map 將多個 Effect 轉換成單一一個 Effect。我們觀察每一階段輸出的 Effect type(註解的部分),可以發現用 Effect.map 我們能轉換每一個階段的「值(A)」,不會改變錯誤(E)與依賴(R),且仍為待執行的 Effect。

組合 Effect: Effect.map

上一個例子中,我們講解了怎麼用 map 組合多個 function。不過這不太夠用,因為我們會希望 Effect 能夠覆蓋整個程式,所以我們 pipeline 中的步驟很多時候會是 Effect 才對,而不是一個值。這樣我們才能妥善的處理 Error 和依賴。所以我們來組合 Effect 吧~

//       ┌─── Effect.Effect<never, Error, never> | Effect.Effect<number, never, never>
//       ▼
const divide = (a: number, b: number) =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b)

//       ┌─── Effect.Effect<Effect.Effect<number, Error, never>, never, never>
//       ▼
const program = pipe(
  // Effect.Effect<number[], never, never>
  Effect.succeed([25, 5]),
  // Effect.Effect<Effect.Effect<never, Error, never> | Effect.Effect<number, never, never>, E, R>
  Effect.map(([a, b]) => divide(a, b))
)
const result = Effect.runSync(program)
console.log(result) // { _id: 'Exit', _tag: 'Success', value: 5 }

從上面程式碼我們可以發現,我們透過 map 將多個 Effect 轉換成單一一個 Effect。但這個輸出是 Effect<Effect<A, E, R>, E, R>,而不是我們想要的 Effect<A, E, R>。因為會造成以下兩個問題:

  1. 型別太繞、不好操作
  • 如果你用 map 得到的結果是 Effect<Effect<number, Error, never>, never, never>,表示最外層是一個「成功拿到內層 Effect」的 Effect,而不是「直接得到值」,不直觀。
  • 換句話說,你還需要再手動去 unwrap,這在型別推導與語意上會很怪。
  1. 失去了 Error handling / 組合的語意
  • map 不會展開 Error,所以如果內層 Effect fail,外層並不知道。
  • 這會讓錯誤流程「卡在內層」,需要手動處理,破壞了原本 Effect 要給你的 自動錯誤傳遞。

所以這時我們需要用 flatMap 來展平多層 Effect:

const program = pipe(
  Effect.succeed([25, 5] as const),
  Effect.flatMap(([a, b]) => divide(a, b))
)
const result = Effect.runSync(program)
console.log(result) // 5

好欸,這樣我們 runSync 後就得到了我們想要的 value 了!

pipeline 中的副作用: Effect.tap

如果今天有一個情境想要在不中斷資料流的情況下產生副作用,我們可以怎麼做呢?

const program2 = pipe(
  Effect.sync(() => Date.now()),
  Effect.map((x) => x * 2),
  Effect.map((x) => {
    console.log(x);
    return x;
  }),
  Effect.map((x) => x.toString()),
  Effect.map((x) => x.toUpperCase()),
);

從上面程式碼,我們直接把副作用寫在 Effect.map 裡;這樣做會導致可觀測性變差。副作用應該要用「Effect 的方式」包起來,Runtime 才看得到。看得到,才記得住誰發生、什麼時候發生、發生幾次;直接寫在 map 裡就像黑箱,外層完全感知不到,沒法做相應的處理。容易發生未預期的錯誤。這時就要介紹我們新的語法 Effect.tap,讓我們執行副作用,但不會改變資料流中的值型別。

const program = pipe(
  Effect.sync(() => Date.now()),
  Effect.map((x) => x * 2),
  Effect.tap((x) => Effect.sync(() => console.log("after double:", x))),
  Effect.map((x) => x.toString()),
  Effect.map((x) => x.toUpperCase())
)

const result = Effect.runSync(program)
console.log(result)

pipeline 中替換回傳值: Effect.as

當你不在意前一步計算出來的值、而只想回傳一個固定常數時,用 Effect.as 最簡潔。它會「忽略目前 Effect 的成功值」,以你提供的常數取代;同時不改變錯誤型別與環境依賴。但請記得 Effect 是不可變的:as 會回傳新的 Effect,不會修改原本的 Effect。

典型情境:

  • 完成一串流程後,只想回傳統一訊息(例如 "OK"、"DONE")。
  • 遮蔽敏感資料:流程需要使用實際值,但外部只應該看到固定占位值。

最小範例:

// 以常數取代原值(5 → "new value")
const program = pipe(
  Effect.succeed(5),
  Effect.as("new value")
)
Effect.runPromise(program).then(console.log) // "new value"

Effect.as(constant)Effect.map(() => constant) 在邏輯上等價,但前者更語意化、可讀性更高。

一次取多個結果再彙整: Effect.all

並行收集多個 Effect 的結果,再做匯總轉換:

const now = Effect.sync(() => Date.now())
const yesterday = Effect.sync(() => Date.now() - 24 * 60 * 60 * 1000)

const arrProgram = pipe(
  Effect.all([now, yesterday]),
  Effect.map(([a, b]) => a + b)
)

const objProgram = pipe(
  Effect.all({ a: now, b: yesterday }),
  Effect.map(({ a, b }) => a + b)
)
console.log(Effect.runSync(arrProgram))
console.log(Effect.runSync(objProgram))

陣列或物件都支援;輸出會保持相同結構,方便取值。

更語意化的工具:zipLeft / zipRight / andThen

  • zipRight(a, b):先做 a 再做 b,最後只回傳 b 的值(a 的值不保留)。
  • zipLeft(a, b):先做 a 再做 b,最後只回傳 a 的值(b 的值不保留)。
  • andThen:接著做某件事。包含:
    • 一個值(類似 Effect.as)
    • 回傳「值」的函式(類似 Effect.map)
    • 一個 Promise
    • 回傳 Promise 的函式
    • 一個 Effect
    • 回傳 Effect 的函式(類似 Effect.flatMap)
import { Console, Effect, pipe } from "effect"

const keepRight = Effect.zipRight(Effect.succeed(5), Console.log("hi"))
const keepLeft = Effect.zipLeft(Effect.succeed("hi"), Effect.succeed(10))

const base = Effect.succeed(5)
const a = Effect.andThen(base, "ok")
const b = Effect.andThen(base, Promise.resolve("ok"))
const c = Effect.andThen(base, Effect.succeed("ok"))
const d = Effect.andThen(base, (x) => `ok ${x}`)

console.log("keepRight", Effect.runSync(keepRight)) // undefined
console.log("keepLeft", Effect.runSync(keepLeft)) // hi
console.log("a", Effect.runSync(a)) // ok
console.log("b", Effect.runPromise(b)) // Promise { <pending> }
console.log("c", Effect.runSync(c)) // ok
console.log("d", Effect.runSync(d)) // ok 5
  • 在 keepRight function 下:先建立一個值為 5 的 Effect,接著執行 Console.log("hi")。因為是用 zipRight 所以保留的最後輸出是 Console.log 的回傳值 undefined,因此最後 runSync 的結果會是 undefined。印出來你會看到:先印 "hi",然後結果是 undefined

  • 在 keepLeft function 下:先產生字串 "hi",再跑一個產生 10 的 Effect,但因為我們只保留第一個(左側)結果,所以最後得到的是 "hi"。

  • 在前一步完成後「接著做某件事」。可以接值、Promise、Effect,或是用函式動態決定下一步要做什麼。

    • a:接一個字串值 "ok",因此結果就是 "ok"。
    • b:接一個 Promise.resolve("ok")runPromise(b) 會回一個 Promise,所以直接 console.log 會看到一個「未完成的 Promise」。等它完成後拿到的是 "ok"。
    • c:接一個 Effect.succeed("ok"),因此解開後的結果是 "ok"。
    • d:接一個函式 (x) => \ok ${x}``,這個函式會用到前一步的值(這裡是 5),所以最後結果是 "ok 5"。

選擇原則:若下一步「不依賴」前一步的值而只是排程後執行,就偏向 zipRight / zipLeft;若「依賴」前一步的值,則 flatMapandThen 更明確。

Effect.gen

當流程有較多條件與分支時,Effect.gen 能用近似 async/await 的方式寫出更直覺的程式,同時保有 Effect 的型別安全與錯誤傳遞:

const programBefore = pipe(
  Effect.sync(() => Date.now()),
  Effect.map((x) => x * 2),
  Effect.flatMap((x) => divide(x, 3)),
  Effect.map((x) => x.toString())
);

const programAfter = Effect.gen(function* () {
  const x = yield* Effect.sync(() => Date.now());
  const y = x * 2;
  const z = yield* divide(y, 3);
  return z.toString();
});

重點:yield* 類似 await,能把 Effect 解開成值;錯誤型別會沿途自動被推導與累積。

Cheatsheet(常用心智模型)

  • map: 我要用前一個結果(A)做轉換,錯誤(E)與依賴(R)不變。
  • flatMap:展平多層 Effect,用來銜接會產生 Effect 的步驟。
  • tap(A => Effect<void>):做副作用,不改值。
  • all:並行收集多個結果再轉換。
  • zipRight / zipLeft:控制要保留哪一步的值。
  • andThen:連結兩個動作,其中第二個動作可以取決於第一個動作的結果。在語意上比其他方法更明確。

總結

在這篇文章中,我們先用 pipe 把資料與轉換排成一條清楚的路,沿途視情境挑選 mapflatMaptapall,必要時再以 aszipLeft/zipRightandThen 強化語意;讓未來建構 Effect 服務時,只在系統邊界執行一次。當出現條件、迴圈或多分支等複雜度時,可以考慮改用 Effect.gen 以近似 async/await 的風格提升可讀性。下一篇我們實際來寫寫看一個複雜的pipeline 體驗一下用 Effect 開發的感覺吧~

參考資料


上一篇
[學習 Effect Day9] 透過組裝 Effect 建構程式 (一)
系列文
用 Effect 實現產品級軟體10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言