iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Modern Web

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

[學習 Effect Day11] 實際建立一個 pipeline

  • 分享至 

  • xImage
  •  

我們前幾個章節講了這麼多 Effect 的語法與知識,但都沒有做啥實際有用的玩意兒。這次不同以往,我們實際來建立一個具有業務場景的 pipeline。讓讀者對 Effect 的程式設計理念更有感覺。

業務情境

我們要算出「應收金額」並顯示給前台/對帳使用。

  • 資料來源與輸出

    • 交易金額:資料庫
    • 折扣率(%):營運後台設定
    • 輸出:可讀字串(例如 Final amount to charge: TWD 96)
  • 規則(必須符合)

    • 交易金額必須 > 0
    • 折扣率需在 (0, 100]
    • 折後交易金額不可為負(可為 0)
  • 流程(快覽)

    1. 並行取得 金額 與 折扣率
    2. 規則檢查(不合就停)
    3. 套用折扣率,得到折後金額
    4. 加上固定手續費 +1
    5. 格式化為展示字串

成功流程描述如下

原始交易金額 100、折扣率 5% → 折後 95;加手續費 1 → 應收 96 → 顯示 Final amount to charge: TWD 96

流程圖

流程圖

逐步實作與解釋

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.tryPromisePromise 轉換成 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 的核心價值:明確資料流、錯誤可控、易於組裝與擴充。

參考資料


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

尚未有邦友留言

立即登入留言