實際建立一個 pipeline
我們前幾個章節講了這麼多 Effect 的語法與知識,但都沒有做啥實際有用的玩意兒。這次不同以往,我們實際來建立一個具有業務場景的 pipeline。讓讀者對 Effect 的程式設計理念更有感覺。
業務情境
我們要算出「應收金額」並顯示給前台/對帳使用。
-
資料來源與輸出
- 交易金額:資料庫
- 折扣率(%):營運後台設定
- 輸出:可讀字串(例如 Final amount to charge: TWD 96)
-
規則(必須符合)
- 交易金額必須 > 0
- 折扣率需在
(0, 100] - 折後交易金額不可為負(可為 0)
-
流程(快覽)
- 並行取得 金額 與 折扣率
- 規則檢查(不合就停)
- 套用折扣率,得到折後金額
- 加上固定手續費 +1
- 格式化為展示字串
成功流程描述如下
原始交易金額 100、折扣率 5% → 折後 95;加手續費 1 → 應收 96 → 顯示 Final amount to charge: TWD 96
流程圖
flowchart TD
A[開始] --> B{並行取得}
B --> C1[讀取交易金額(DB)]
B --> C2[讀取折扣率(後台設定)]
C1 --> D[彙整結果]
C2 --> D
D --> V1{金額 > 0?}
V1 -->|否| E1[錯誤:金額需為正數]
V1 -->|是| V2{折扣率 ≤ 0 或 > 100?}
V2 -->|是| E2[錯誤:折扣率需大於0且小於等於100]
V2 -->|否| F[計算折扣後金額]
F --> G[加服務手續費 +1]
G --> H[格式化為幣別字串(zh-TW/TWD,無小數)]
H --> I[輸出 'Final amount to charge: TWD 金額']逐步實作與解釋
1. 手續費純函數(單一職責、無副作用)
/** 加入固定服務費(純函式,無副作用) */
const addServiceCharge = (amount: number): number => amount + 1專注在加上固定手續費,不做其他事;容易測試與替換。
2. 折扣率商業邏輯(折扣率≤0 或 >100 擋下、原始交易金額>0、折後可為 0 但不可負)
/**
* 套用折扣(含輸入驗證)。
* @param total 總金額(必須 > 0)
* @param discountRate 折扣百分比(0 < rate ≤ 100)
* @returns Effect<number, Error> 折扣後金額或錯誤
*/
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> => {
if (total <= 0) {
return Effect.fail(new Error("Total must be positive"))
}
if (discountRate <= 0 || discountRate > 100) {
return Effect.fail(new Error("Discount rate must be in (0, 100]"))
}
const discounted = total - (total * discountRate) / 100
return Effect.succeed(discounted)
}把規則寫成「會報錯」的流程:先檢查原始交易金額大於 0,再擋下折扣率小於等於 0 和大於 100 的情況(營運後台系統對我們來說不可控,嚴謹上必須確認)。透過上面條件我們能確保最後交易金額會是一個大於等於 0 的數字。
3. 透過 Effect.tryPromise 模擬 API 的非同步行為
我們先做一個 mock API 的 function,用來模擬 API 的非同步行為,再用 Effect.tryPromise 把 Promise 轉換成 Effect。
type ApiSuccess<T> = { status: 200; data: T }
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
/**
* 模擬 API 請求(以 setTimeout 延遲回傳)。
* @param data 回傳資料
* @param ms 延遲毫秒數(預設 120)
* @param shouldFail 是否強制失敗(預設 false)
* @param error 失敗時回傳的錯誤物件(預設 500 Mock error)
* @example 成功:mockFetch({ id: 1, name: "Alice" }, 120, false).then(console.log)
* @example 失敗:mockFetch({ id: 1 }, 120, true).catch(console.error)
*/
const mockFetch = <T>(
data: T,
ms = 120,
shouldFail = false,
error: ApiError = new ApiError(500, "Mock error")
): Promise<ApiSuccess<T>> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(error)
return
}
resolve({ status: 200, data })
}, ms)
})
}
/**
* 將 Promise<ApiSuccess<T>> 轉為 Effect,並統一錯誤為 Error。
* - 只擷取成功回應的 data 欄位。
*/
const fromApi = <T>(promise: Promise<ApiSuccess<T>>): Effect.Effect<T, Error> => {
return pipe(
Effect.tryPromise({
try: () => promise,
catch: (error: unknown) => error instanceof ApiError ? error : new Error(String(error))
}),
Effect.map((res) => res.data)
)
}
// 取得交易金額與折扣比例(模擬 API)
const fetchTransactionAmount = fromApi(mockFetch(100, 120))
const fetchDiscountRate = fromApi(mockFetch(5, 80))因為我們沒有實際 API 可以用,所以創建一個 mockFetch 模擬延遲且支援成功/失敗回應的 Mock API;再用 fromApi 把 Promise 轉成 Effect、取出 data 並統一錯誤型別。這樣既像 API,又方便測試。
4. 並行取得 API response 並根據 response 轉換成折扣後交易金額
/**
* 先並行取得金額與折扣,接著套用折扣邏輯。
*/
const discountedAmountEffect = pipe(
Effect.all([fetchTransactionAmount, fetchDiscountRate]),
Effect.andThen(([transactionAmount, discountRate]) => applyDiscount(transactionAmount, discountRate))
)Effect.all 並行取值;andThen 使用前一步結果根據 applyDiscount 將交易金額與折扣率轉換成折扣後交易金額。
5. 建立一個格式化金額的 function
/**
* 將金額格式化為幣別字串(預設 zh-TW / TWD)。
*/
const formatAmount = (
amount: number,
locale: string = "zh-TW",
currency: string = "TWD"
): string => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
currencyDisplay: "code",
maximumFractionDigits: 0
}).format(amount)
}組裝 pipeline 並執行
/**
* 建立主程式流程:折扣 → 加入服務費 → 輸出格式化字串。
*/
const buildProgram = (): Effect.Effect<string, Error> => {
return pipe(
discountedAmountEffect,
Effect.andThen(addServiceCharge),
Effect.andThen((finalAmount) => `Final amount to charge: ${formatAmount(finalAmount)}`)
)
}
// 執行並輸出結果
Effect.runPromise(buildProgram()).then(console.log)
// 輸出:Final amount to charge: TWD 96完整程式碼
import { Effect, pipe } from "effect"
const addServiceCharge = (amount: number): number => amount + 1
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> => {
if (total <= 0) {
return Effect.fail(new Error("Total must be positive"))
}
if (discountRate <= 0 || discountRate > 100) {
return Effect.fail(new Error("Discount rate must be in (0, 100]"))
}
const discounted = total - (total * discountRate) / 100
return Effect.succeed(discounted)
}
type ApiSuccess<T> = { status: 200; data: T }
type ApiError = { status: number; message: string }
const mockFetch = <T>(
data: T,
ms = 120,
shouldFail = false,
error: ApiError = { status: 500, message: "Mock error" }
): Promise<ApiSuccess<T>> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(error)
return
}
resolve({ status: 200, data })
}, ms)
})
}
const fromApi = <T>(promise: Promise<ApiSuccess<T>>): Effect.Effect<T, Error> => {
return pipe(
Effect.tryPromise({
try: () => promise,
catch: (err) =>
err && typeof err === "object" && "message" in (err as any)
? new Error((err as any).message)
: new Error(String(err))
}),
Effect.map((res) => res.data)
)
}
const fetchTransactionAmount = fromApi(mockFetch(100, 120))
const fetchDiscountRate = fromApi(mockFetch(5, 80))
const discountedAmountEffect = pipe(
Effect.all([fetchTransactionAmount, fetchDiscountRate]),
Effect.andThen(([transactionAmount, discountRate]) => applyDiscount(transactionAmount, discountRate))
)
const formatAmount = (
amount: number,
locale: string = "zh-TW",
currency: string = "TWD"
): string => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
currencyDisplay: "code",
maximumFractionDigits: 0
}).format(amount)
}
const buildProgram = (): Effect.Effect<string, Error> => {
return pipe(
discountedAmountEffect,
Effect.andThen(addServiceCharge),
Effect.andThen((finalAmount) => `Final amount to charge: ${formatAmount(finalAmount)}`)
)
}
Effect.runPromise(buildProgram()).then(console.log)總結
本文以一個「應收金額」的實務場景,示範如何以 Effect 建立穩健、可組裝的 pipeline:
- 以純函式分離小步驟(加手續費、格式化),提高可測試性與可替換性。
- 以
Effect.tryPromise包裝非同步 I/O,統一錯誤為Error並取用有效資料。 - 以
Effect.all並行取得相依資料,配合andThen串接商業邏輯(折扣檢核 → 計算 → 加手續費 → 格式化)。 - 輸入驗證放在邊界(折扣規則、金額必須為正),失敗即早退出,確保後續運算的前置條件正確。
最後輸出像是:Final amount to charge: TWD 96,讓前台/對帳可直接使用。這個範例展示了 Effect 的核心價值:明確資料流、錯誤可控、易於組裝與擴充。
參考資料
- Building Pipelines
- GPT-5
Last updated on