TypeScript 里依赖藏在 import 里有什么问题?

看这样一个 signup:它的签名是 (email: string) => Promise<void>,只说要一个邮箱,函数体里却同时用了数据库和邮件客户端。

问题在于依赖藏在 import 语句里,是模块级单例:签名看不出它碰了什么,测试和替换也只能以整个文件为粒度。手动传参和 DI 容器各修了一半,Effect 把依赖写进 R 类型参数,才把两半合上。

下面先看这种写法具体坏在哪,再看两种常见改进为什么不够,最后看 Effect 的做法。

藏在 import 里的依赖坏在哪?

import { mailer } from "./infra/mailer"
import { db } from "./infra/db"

export async function signup(email: string) {
  await db.users.insert({ email })
  await mailer.send(email, "welcome")
}

这种写法的代价,在三个场景里会同时出现:

  • 测试时只能 mock 整个模块,替换的粒度是文件而不是能力。
  • 想在某个调用链里临时换一个实现(比如演练模式下不真发邮件),要么改全局,要么到处加开关参数。
  • 读签名读不出依赖,读者必须翻完函数体才知道它碰了哪些外部系统。

手动传参和 DI 容器为什么不够?

第一个改进是手动传参:signup(db, mailer, email)。依赖显式了,但调用链一深,中间函数就要替下游转发自己根本不用的参数。

第二个改进是 DI 容器加装饰器。参数不用传了,但依赖关系变成运行时反射,缺了一个 provider,要等到启动甚至首次调用才报错。

两者其实在解决同一件事:让依赖可见,同时不让它污染每一层的参数列表。手动传参牺牲了后者,容器牺牲了前者的静态检查。

Effect 怎么同时做到这两点?

在 Effect 里,Effect<A, E, R> 的第三个参数 R 记录「运行这个 Effect 需要什么」。业务代码取用一个服务,R 里就自动多一项;多个服务组合时自动取并集,中间函数不用转发任何东西。缺依赖就想运行,会在编译期报错,而不是等到运行时。

具体怎么定义服务、取用、提供实现,见 Effect 里怎么用 Context.Service 把依赖写进类型。R 本身的来历见 Effect 是一个值。

这篇的论述来自教程章节的整理,没有额外运行代码验证。「缺依赖在编译期报错」是原章节的描述,这里没有再验证,也没有记录对应的 effect 版本号。