想像你在應用程式裡到處傳 databaseService
、loggingService
,很快地每個函式都在接收、轉傳一堆 Service,變得又厚又難測。Effect 的做法是:把「誰需要什麼服務」寫進型別,由編譯器幫你把關與組裝。
在程式設計中,Service 指的是提供特定功能的可重複使用元件,可用於應用程式的不同部分。像是 Database、Logger、Http 都是典型的 Service。這種設計讓業務邏輯能依賴「功能本身」,而不是自己實現某個特定的實作,程式就能解耦。因此更容易替換、測試與維護,不必改動核心邏輯。
最直覺的作法是把 Service 當參數一路往下傳。如下方程式碼所示:
async function processUserOrder(
userId: string,
orderId: string,
userService: UserService,
databaseService: DatabaseService,
loggerService: LoggerService,
emailService: EmailService,
cacheService: CacheService,
paymentService: PaymentService,
notificationService: NotificationService
) {
// 實際只用了其中幾個服務
const user = await userService.getUser(userId);
const order = await databaseService.query(`SELECT * FROM orders WHERE id = ${orderId}`);
// 呼叫下層函式時,又要傳遞所有參數
return await sendOrderConfirmation(
user, order, userService, databaseService,
loggerService, emailService, cacheService,
paymentService, notificationService
);
}
你會發現上層函式為了呼叫下層,不得不接受很多自己其實不使用的參數,導致「參數膨脹」。
另一種常見作法是塞一個大 Context
// 把所有服務都塞進一個大 Context
interface AppContext {
userService: UserService;
databaseService: DatabaseService;
loggerService: LoggerService;
emailService: EmailService;
cacheService: CacheService;
paymentService: PaymentService;
notificationService: NotificationService;
configService: ConfigService;
metricsService: MetricsService;
auditService: AuditService;
// ... 還有更多服務
}
// 因為 processUserOrder 接受的 context 包含所有服務,所以得確保 context 中所有服務在執行期都有妥善提供。
async function processUserOrder(userId: string, orderId: string, context: AppContext) {
const user = await context.userService.getUser(userId);
const order = await context.databaseService.query(`SELECT * FROM orders WHERE id = ${orderId}`);
// 這個函式到底需要哪些服務?從 Function Signature 看不出來,閱讀不易
return await sendOrderConfirmation(user, order, context);
}
function testProcessUserOrder() {
const mockContext: AppContext = {
userService: mockUserService,
databaseService: mockDatabaseService,
// 必須提供所有服務,即使測試用不到 => 測試變得複雜
loggerService: mockLoggerService,
emailService: mockEmailService,
cacheService: mockCacheService,
paymentService: mockPaymentService,
notificationService: mockNotificationService,
configService: mockConfigService,
metricsService: mockMetricsService,
auditService: mockAuditService,
};
}
雖然少傳了參數,但函式的真正需求被藏起來,初始化也容易在執行期才爆錯。兩者都讓複合函數(functional composition)與測試變得很不容易。
Effect 透過「型別化的環境 Requirements」來描述一段運算需要哪些 Services。
Effect 型別:Effect<Success, Error, Requirements>
never
。每段 Effect 只宣告自己要的功能,最後在應用邊界一次性 provide。少了「大 Context 的隱性耦合」、也避免「手動傳參的參數滑坡」。更關鍵的是:如果你忘了提供某個 Service,型別檢查會在編譯期就提醒你,而不是到執行期才出錯。
那具體要如何做呢?我們一起看下去吧~
定義一個 Service 時,我們需要使用 Effect 中的 Context
。Context
是一個型別安全的服務容器,用來儲存和提供服務實例。當我們提供具體的實作時,Effect 會從 Context
中提取對應的服務。你可以將 Context
想像成一個服務註冊表,用 Tag
來索引服務。
type Context = Map<Tag, Service>
我們用一個產生隨機數的 Service 當例子🌰。
import { Context, Effect } from "effect";
// 以唯一字串識別建立 Tag
class Random extends Context.Tag("MyRandomService")<
Random, // 這裡的 Random 就是指向這個 class 自己
// service 會回傳一個 number 的 Effect
{ readonly next: Effect.Effect<number> }
>() {}
要特別注意的是 Tag
需要是唯一識別字串,確保同一識別字串在整個程式或熱重載後還是會對應到同一個服務實例。
你可以把 Tag 當作一個可 yield*
的「抽取服務」效果來用。
// ┌─── Effect<void, never, Random>
// ▼
const program = Effect.gen(function*() {
const random = yield* Random
const randomNumber = yield* random.next
console.log(`random number: ${randomNumber}`)
})
在執行前 Effect.runSync(program)
就會先在編輯器上看到:
Missing 'Random' in the expected Effect context.effect(1)
若直接執行 Effect.runSync(program)
,也會出現類似的錯誤,如下:
node:internal/modules/run_main:123
triggerUncaughtException(
^
Error: Service not found: MyRandomService (defined at <anonymous> (/Users/eric/personal-project/effect-app/src/day20/Program.ts:4:51))
.....
這些錯誤都是在告訴我們的 program 缺少 Random 服務。
使用Effect.provideService
綁定服務到 program 上並提供服務的實作Effect.sync(() => Math.random())
。
// Providing the implementation
//
// ┌─── Effect<void, never, never>
// ▼
const runnable = Effect.provideService(program, Random, {
next: Effect.sync(() => Math.random())
})
Effect.runSync(runnable)
// 輸出:
// random number: 0.8955022180738443
綁定後你可以發現,原本 program 是有 Requirements 的,現在變成 never
。所以可以直接執行 Effect.runSync(runnable)
了。
// 新定義一個 Logger 服務
class Logger extends Context.Tag("MyLoggerService")<
Logger,
{ readonly log: (message: string) => Effect.Effect<void> }
>() {}
// 將 program 改成有用到 Random 與 Logger 兩個服務
const program = Effect.gen(function*() {
const random = yield* Random
const logger = yield* Logger
const randomNumber = yield* random.next
yield* logger.log(String(randomNumber))
})
// program 的需求是 Random | Logger
const runnable = program.pipe(
Effect.provideService(Random, {
next: Effect.sync(() => Math.random())
}),
Effect.provideService(Logger, {
log: (message) => Effect.sync(() => console.log(`[${new Date().toLocaleString()}] ${message}`))
})
)
Effect.runSync(runnable)
// 輸出:
// [9/30/2025, 2:23:28 AM] 0.32955400245685196
我個人比較喜歡這個 pattern。乾淨 ❤️。
const context = Context.empty().pipe(
Context.add(Random, { next: Effect.sync(() => Math.random()) }),
Context.add(Logger, {
log: (message) => Effect.sync(() => console.log(`[${new Date().toLocaleString()}] ${message}`))
})
)
const runnable = Effect.provide(program, context)
Effect.runSync(runnable)
// 輸出:
// [9/30/2025, 2:30:16 AM] 0.41678327901764756
Effect.serviceOption
當服務「可有可無」時,用 serviceOption
。它回傳 Option<Service>
,未提供時是 None
。嘿嘿~😌還好Option
的觀念我們之前的文章有講,沒有偷懶。所以這邊我就不贅述啦!
import { Effect, Context, Option } from "effect"
const program = Effect.gen(function*() {
const maybeRandom = yield* Effect.serviceOption(Random)
const randomNumber = Option.isNone(maybeRandom)
? -1 :
yield* maybeRandom.value.next
console.log(randomNumber)
})
// 未提供 Random → 輸出 -1
Effect.runSync(program)
// 有提供 Random → 輸出真的隨機數
Effect.runSync(
Effect.provideService(program, Random, {
next: Effect.sync(() => Math.random())
})
)
// 輸出:
// -1
// 0.7736486052039062
重點:因為有處理缺少Random
服務的情況,program
的 Requirements 是 never
。
Context.Tag.Service
可以透過 Context.Tag.Service
抽出服務回傳的型別
import { Context } from "effect";
type RandomShape = Context.Tag.Service<Random>;
// 等同於:{ readonly next: Effect.Effect<number> }
因為需求寫在型別上,測試時直接提供假的 Service 即可。
// 被測函式:回傳一個隨機數(方便斷言)
const drawNumber = Effect.gen(function*() {
const random = yield* Random
return yield* random.next
})
// 測試:固定回傳 0.5
const testEffect = drawNumber.pipe(
Effect.provideService(Random, { next: Effect.succeed(0.5) })
)
console.log(Effect.runSync(testEffect))
// 輸出:0.5
Effect<..., Random | ...>
不能執行。provideService
或 provide
(整包 Context)補齊。Context.Tag("UniqueId")
的字串在專案中全域唯一。Effect.serviceOption(Tag)
,處理 Option
即可。本文介紹了 Effect 如何透過型別化的服務需求來解決傳統依賴注入的問題,避免手動傳參數或大 Context 的缺點。Effect 讓每段程式只宣告自己需要的服務,編譯器會在編譯期就檢查你是否提供了所有必要的服務,而不是等到執行期才出錯。
全文透過產出隨機數的服務作為例子,展示了如何定義、使用和提供服務,以及如何處理可選服務和進行測試。這種方式讓程式可以更模組化、更容易測試。
下一篇我們將介紹 Layer
,來讓服務中還依賴其他服務的情況,對外介面還是可以保持乾淨(Requirements 為 never)。