我們前幾個章節講了這麼多 Effect 的語法與知識,但都沒有做啥實際有用的玩意兒。這次不同以往,我們實際來建立一個具有業務場景的 pipeline。讓讀者對 Effect 的程式設計理念更有感覺。
我們要算出「應收金額」並顯示給前台/對帳使用。
資料來源與輸出
規則(必須符合)
(0, 100]
流程(快覽)
原始交易金額 100、折扣率 5% → 折後 95;加手續費 1 → 應收 96 → 顯示 Final amount to charge: TWD 96
/** 加入固定服務費(純函式,無副作用) */
const addServiceCharge = (amount: number): number => amount + 1
專注在加上固定手續費,不做其他事;容易測試與替換。
/**
* 套用折扣(含輸入驗證)。
* @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 的數字。
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,又方便測試。
/**
* 先並行取得金額與折扣,接著套用折扣邏輯。
*/
const discountedAmountEffect = pipe(
Effect.all([fetchTransactionAmount, fetchDiscountRate]),
Effect.andThen(([transactionAmount, discountRate]) => applyDiscount(transactionAmount, discountRate))
)
Effect.all
並行取值;andThen
使用前一步結果根據 applyDiscount
將交易金額與折扣率轉換成折扣後交易金額。
/**
* 將金額格式化為幣別字串(預設 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)
}
/**
* 建立主程式流程:折扣 → 加入服務費 → 輸出格式化字串。
*/
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 的核心價值:明確資料流、錯誤可控、易於組裝與擴充。