跳到正文
Hacker News· nilsbunger·· 7 天前AI 评分22

为什么该用 Effect TS:从 Promise 的类型缺陷说起

Understanding why you'd use Effect TS

AI 导读

Effect TS 让 TypeScript 函数把失败类型写进返回类型:checkout 调用链中新增的 CountryNotServiced 错误会一路向上传播,直到必须处理它的地方才报编译错误,而 Promise 的返回类型里完全不体现失败。

正文 · AI 翻译

˜˜˜˜

这篇文章是写给那些最近听说过 Effect,但还没完全搞明白它,或者更实际一点,只想知道自己该不该用它的人。

如果你是 TypeScript 新手,只想要一个建议:用吧。你要改掉的旧习惯比我们其他人少,而且 Effect 让你思考的那些东西,你迟早都得学。如果你想知道人们为什么喜欢它,那就继续读下去。

Effect 让人困惑的地方在于它的体量。它导出的东西非常多,而且在很长一段时间里(尤其是 v3 的文档),你很难透过庞大的 API 表面看到它的好处。所以这篇文章不是带你逛一遍 API,而是按我认为的、大致以可辩护程度排序,列出它带来的好处。前两点是客观的。之后的一切都关乎开发者体验,你应该把它当作一个人的观点,而不是普遍真理。

1. Promise 不会告诉你它是怎么失败的

假设你刚写了这段代码:

export async function checkout(cart: Item[], customer: Customer) {
  const order = await createOrder(cart)
  const payment = await charge(customer)
  const shipment = await ship(order, payment)
  return await createReceipt(order, shipment)
}

现在回答一个问题:这里可能出什么错?

你从代码里看不出来,编译器也看不出来。如果明天我们不再向某个国家发货,而 ship 开始抛出一个新错误,checkout 的任何调用方会发现吗?不会。即使你把整段代码包在 try/catch 里,不靠运行时拆解错误对象,你也不知道是四步中的哪一步失败了。这个函数的类型是 Promise<Receipt>。“失败”这个词根本没出现在里面。

下面是同一个函数在 Effect 里的写法:

const checkout = (cart: Item[], customer: Customer) =>
  Effect.gen(function* () {
    const order = yield* createOrder(cart)
    const payment = yield* charge(customer)
    const shipment = yield* ship(order, payment)
    return yield* createReceipt(order, shipment)
  })

// Effect<Receipt, EmptyCart | CardDeclined | CountryNotServiced>

没人手写那个错误联合类型。它是从被调用的四个函数推断出来的,这些函数的签名长这样:

createOrder:   (cart: Item[])                    => Effect<Order, EmptyCart>
charge:        (customer: Customer)              => Effect<Payment, CardDeclined>
ship:          (order: Order, payment: Payment)   => Effect<Shipment, CountryNotServiced>
createReceipt: (order: Order, shipment: Shipment) => Effect<Receipt>

那么当有人把 CountryNotServiced 加到 ship 里时会发生什么?checkout 本身仍然能编译。它的错误类型只是变宽了,所有调用它的东西的类型也变宽了,一路沿着调用栈向上。编译器会在第一个要求这些错误被完全处理的地方报错。通常那是你程序的边界,错误在那里被转换成响应:

const handler = checkout(cart, customer).pipe(
  Effect.map((receipt) => reply(200, receipt.id)),
  Effect.catchTags({
    EmptyCart: () =>
      Effect.succeed(reply(400, "cart is empty")),
    CardDeclined: (error) =>
      Effect.succeed(reply(402, error.reason)),
  }),
)

// Effect<Reply, CountryNotServiced>
//               ^ still here

任何要求 effect 被完全处理的地方都会拒绝它:

Type 'CountryNotServiced' is not assignable to type 'never'.

这会帮你省掉一大堆头疼……相信我。现在,预期错误成了返回类型的一部分,所以三个函数深处新加的一个错误,会在必须处理它的地方变成编译错误。

这不仅仅是主观上的改进。这不是“看起来更好看”。一类过去由你的用户发现的 bug,现在由编译器发现。

“但 neverthrow 也能做到这一点”

确实能。那 Effect 有什么特别之处?

问十个人你会得到十个答案,但我认为他们会有一个共同点:Effect 是第一个让我们能以真正满意的方式进行真正函数式编程的 TypeScript 库。

之前的尝试——最著名的是 fp-ts——是正确的,但我从来不喜欢它们让我的代码变成的样子。我知道函数式编程的好处。我只是无法想象把一个人带到 pipe(chain(map(...))) 上,并让他觉得这值得。

所以 Effect 并没有让以前不可能的事情变得可能。它让函数式编程成为你在代码审查中不必为之道歉的东西,而且在很多地方它直接改善了开发者体验。

类型化错误是其中的第一部分。下一部分是代码真正运行的时候。

2. Promise 是急切的

关于 Promise,有一点很容易被忘记:

const receipt = checkout(cart, customer)

结账已经开始了。你还没有 await 任何东西,但我们已经在创建订单了。await 是你等待结果的地方,而不是你允许它开始的地方。

大多数时候,这正是你想要的。但假设你想重试一个操作:

const user = fetchUser(id)

await retry(user)

retry 拿着这个 promise 能做什么?它可以等待它。它可以发现它失败了。它无法重新发起请求。这个 promise 代表的是一次特定执行的结果,而那次执行已经开始了。

所以你改为传入一个函数:

await retry(() => fetchUser(id))

现在 retry 有了可以再次调用的东西。你给它的是如何开始这项工作的描述,而不是你已经启动的工作的结果。

同样的问题也出现在并发场景中:

const users = ids.map(fetchUser)

await limitConcurrency(users, 5)

太晚了。对 fetchUser 的每次调用都已经发生了。无论 limitConcurrency 做什么,它都无法回头只启动其中五个。

同样,你通过传入函数来解决这个问题。这完全合理,但请注意这个模式:每当你想控制执行时,你就必须停止传递 promise,转而传递能产生 promise 的东西。

Effect 就是从这里开始的。

const user = Effect.tryPromise(() => fetchUser(id))

什么都没有被获取。user 是对这项工作的描述,我们可以在开始之前决定希望这项工作如何运行。这就是 Effect 的 惰性执行模型 背后的区别。

const users = ids.map((id) =>
  Effect.tryPromise(() => fetchUser(id)),
)

const program = Effect.all(users, { concurrency: 5 })

// Still no requests.

await Effect.runPromise(program)

// Now we run them, at most five at a time.

这就是人们说“effect 是值”时的意思。你可以创建它们,把它们放进数组,把它们传给另一个函数,并用它们构建一个更大的程序,而不会在这个过程中意外启动程序。当你调用类似 Effect.runPromise 的东西时,运行时才会执行它。

当我第一次看到这个时,感觉像是多了一步。我已经知道我想获取用户。为什么要描述获取用户,然后单独运行这个描述?

因为知道如何获取用户的函数不应该同时还得决定我们一次允许多少个请求,或者失败的请求是否应该重试。这些决定属于使用它的代码。延迟执行给了那段代码做出这些决定的机会。

而且执行并不是我们唯一可以留到以后做的决定。我们还可以让代码将使用哪些服务保持开放。这就是第三个类型参数的用武之地。

2. 我们一直想要的服务依赖方式

看看核心 Effect 类型。与 neverthrow 不同,也与你见过的每个 Option/Result 类型不同——它们有一个成功类型和一个错误类型——这里还有第三个参数:

       ┌─── Success type
       │  ┌─── Error type
       │  │  ┌─── Requirements 🧐
       ▼  ▼  ▼
Effect<A, E, R>

我敢打赌,对于我们这些最初持怀疑态度的人来说,理解那个第三个类型的那一刻,就是我们从不“所以这又是一个 neverthrow”转变为“好吧,我要用这个”的时刻。

让这一切成为可能的想法是这样的。一个 Effect<A, E, R> 是计算的描述,而不是正在运行的计算。因为还没有任何东西被执行,类型可以携带“这需要一个 Database 和一个 Clock”,而程序边缘的某个东西可以在以后提供它们。类似于 React 在没告诉人们的情况下让所有人都用上了函数式编程,Effect 在这里偷偷塞进了两个想法:

  1. Effect 作为值。
  2. 依赖注入。
import { Context, Effect, Layer } from "effect"

// Services: first we defined the interface
// Think of these like `interface Db { find: () => ... }`
class Db extends Context.Service<Db, {
  readonly find: (id: string) => Effect.Effect<User>
}>()("@app/Db") {}

class Log extends Context.Service<Log, {
  readonly info: (msg: string) => Effect.Effect<void>
}>()("@app/Log") {}

// Nobody declares dependencies. They're inferred from use and bubble up.
const getUser = (id: string) =>
  Effect.gen(function* () {
    const db = yield* Db
    return yield* db.find(id)
  })
// Effect<User, never, Db>

const greet = (id: string) =>
  Effect.gen(function* () {
    const user = yield* getUser(id)
    const log = yield* Log
    yield* log.info(`hi ${user.name}`)
    return user
  })
// Effect<User, never, Db | Log>
// Db came along from getUser. Log was added here. greet never mentioned Db.

// Implementations: wire once, at the edge.
const DbLive  = Layer.succeed(Db,  { find: (id) => Effect.succeed({ id, name: "Cooper" }) })
const LogLive = Layer.succeed(Log, { info: (m) => Effect.sync(() => console.log(m)) })

Effect.runPromise(greet("1").pipe(Effect.provide(Layer.merge(DbLive, LogLive))))

// Test: swap one layer. Zero application code touched.
const DbTest = Layer.succeed(Db, { find: () => Effect.succeed({ id: "x", name: "fixture" }) })
Effect.runPromise(greet("1").pipe(Effect.provide(Layer.merge(DbTest, LogLive))))

// Forgot Log? runPromise requires R = never. Compile error, not a 3am null pointer.
Effect.runPromise(greet("1").pipe(Effect.provide(DbLive)))
//   Argument of type 'Effect<User, never, Log>' is not assignable to
//   parameter of type 'Effect<User, never, never>'.

对我来说,这就是人们说自己“爱”Effect 的主要原因,而这对一个 JavaScript 库来说并不是一种正常的感受。

我们没有人会对自己代码的状态完全满意,我想我们中的很多人,尤其是那些在 Rails、Django 或 Elm 架构上花过时间的人,一直觉得 TypeScript 始终没有获得它那个约定重于配置的框架。我们喜欢拿 WordPress 开玩笑,但看看它的插件系统是怎么运作的:有些企业唯一的产品就是一个 WordPress 插件,因为这套架构让它的扩展点变得明确。Requirements 通道就是用来构建这类东西的原语。一段代码明确说出它需要什么,然后由框架来提供。

3. 心智模型:输出与输入

当我回想自己刚开始编程的时候,我能看出自己当时会如何看待上面的代码,并且看不出它有什么价值。等你工作了一段时间之后,再看它,就会立刻意识到它让测试变得容易了多少,或者让同一段代码在多个运行时上运行变得容易了多少。这一节是写给还没到那一步的读者的。

错误是返回值。它们像成功值一样,从函数中流出。需求则流入。对于一个纯函数来说,这是任何东西唯一能走的两个方向,所以一旦签名同时覆盖了这两者,A 和 E 出去、R 进来,关于这个函数与世界的关系,就没有什么可再发现的了。签名就是上下文。

这改变了调试的感觉。大多数时候,你从定义函数的那一行就能得到答案。你不再做的事情,是我们都做过一千遍的那件事:读一行,在心里给它做个书签,向上滚动找到它引用的东西是在哪里导入的,给那里做个书签,跳进另一个文件,再做个书签,再回来,然后祈祷你脑子里的那摞书签还没倒。

纯函数

在纯函数式语言里,规则是函数不允许触碰任何没有作为参数传给它的东西。

function foo(a, b) {
  return a + b
}

foo 可能做的一切都由 a 和 b 决定。在 JavaScript 里,我们不断地违反这一点。当你在函数内部 console.log 的那一刻,你就已经打破了规则。如果你试着重写代码来遵守它,你会发现它给你加了很多约束,一开始这很烦人。但有些程序员看到的是烦人,我们中的很多人看到的是一整类错误的消除。

大多数 bug 的根本原因是复杂性,而清晰的契约就是对抗复杂性的方式。在一个纯函数里,当出问题时,参数就是整个搜索空间。在一个非纯函数里,搜索空间是函数体,再加上它触及的每一个全局变量和每一个模块。这就是读一行和扫描整个函数体之间的区别,而后者要复杂许多倍。

隐藏的依赖

让我们回到结账的例子,看看 charge:

import { stripe } from "./stripe"   // hidden dependency
import { db } from "./db"           // hidden dependency

const charge = async (customer: Customer) =>
  stripe.charges.create({ customer: customer.id })

你见过很多这样的代码。你也写过很多这样的代码,而且确实很难看出它有什么问题。即使你去掉 async 并让它返回一个 Effect,那两个导入仍然会待在文件顶部。

这不是关于忘记导入的问题;TypeScript 会抓住那个。这是关于依赖住在哪里的问题。当年 Webpack 打包我们所有应用的时候,一个模块实际上就是一个包裹你文件的 IIFE,而那些导入就是它的自由变量。这仍然是思考它们的正确方式:charge 向自身之外去够 stripe,而它的签名里没有任何东西说明这一点。

调试这个就像之前提到的书签舞。你在 charge 里读一行,思考一下,滚动到顶部看看 stripe 来自哪个模块,再滚回来,如此反复。

下面是用服务实现的同样功能:


// Define the interface
class Payments extends Context.Service<
	Payments,
	{
	  charge: (customer: Customer) => Effect.Effect<StripeReceipt>
	}
>()("@app/Payments") {}


// Implementation: typically defined in another file
const PaymentsLive = Layer.effect(Payments, Effect.gen(function* () {
  // Payments' own dependencies live here, not in charge
  const stripe = yield* Stripe   
  return {
      charge: (customer: Customer) => stripe.charge(customer.id),
    }
}))

const charge = (customer: Customer) =>
  Effect.gen(function* () {
    const payments = yield* Payments
    return yield* payments.charge(customer)
  })
// Effect<Charge, CardDeclined, Payments>

看起来几乎一样。函数体几乎没有变化。但通过在函数体内使用 Payments,我们改变了 charge 的类型。它的签名现在写着 Payments,而这个词做了很多事:

  • 任何读到签名的人都知道 charge 会与支付系统通信,无需打开函数体。
  • 任何调用 charge 的人都会继承这个要求。它会一直向上冒泡,直到某个东西提供它,如果没有任何东西提供,程序就无法编译。
  • 测试 charge 意味着提供一个不同的 Payments 层,而不是 vi.mock("./stripe") 然后指望模块图配合。
  • 上下滚动舞消失了。文件顶部的导入是类型和标签,不是带有行为的活对象。函数能做什么完全由它的签名决定。

理解一个函数的心理成本从随文件大小增长变成了恒定。

4. 你无法越过的护栏

在所有条件相同的情况下,如果我必须在易于理解的代码和稍快但更难理解的代码之间选择,我会毫不犹豫地选择易于理解。简单、可维护的代码才能带来人们喜欢使用的软件。当代码库复杂时,一切成本都更高,于是东西就被忽视了。看看大多数生产环境的 TypeScript,你就能看到结果。

随着你编程水平提高,你开始想要更严格的约定,我不认为这是因为你怀疑自己的纪律。而是因为你无法控制后来的人。在我用过的所有框架中,Rails 做得最好。它决定了模型如何定义、中间件长什么样、数据如何到达前端,结果大多数 Rails 应用看起来都一样。你可以从一个项目转到另一个,感觉不像是在不同的代码库。TypeScript 从未有过这一点,而我认为这正是经验丰富的开发者在开始使用 Effect 时认识到的:它强加的形状让你找回了一些那种感觉。

每个代码库都有一些区域,当你接近它们时会感到一丝警觉。就像走在湿滑的大理石上。你知道有人会在这里滑倒。传统的修复方法是放一个黄色标志:一条 lint 规则、一大块注释、README 里的警告。程序员讨厌这种修复。如果有人不看标志,他们还是会摔倒,而总有人不看标志。我们真正想要的是一块不会滑倒的地板。

这就是 Effect 给人的感觉。上面的隐藏依赖 charge 正是那种我会放标志的地方,而有了 Effect,没有什么需要警告的,因为错误无法通过类型检查。终于有了真正的解决方案,而不是建议。

这一点现在比两年前更重要,因为智能体写了我们大量的代码,而智能体在阅读标志方面至少和人一样糟糕。类型错误是智能体无法越过的标志。

那么,你应该使用它吗?

  • 如果你的代码有有意义的 I/O,即外部 API、数据库、队列、配置、重试,并且同时涉及其中多个,那么是的。这正是类型化错误和 Requirements 通道物有所值的地方。
  • 如果你在写小脚本或纯数据转换,普通 TypeScript 就够了。Effect 不是简单代码的替代品,把简单代码包在里面只会增加仪式感。
  • 如果你在团队中,要预留时间来推销它,而不仅仅是安装它。

客观的论据是第 1 节:编译器现在会告诉你过去只有在生产环境中才会发现的失败模式。之后的一切都关乎在一个签名告诉你真相的代码库中工作是什么感觉。对我来说,这种感觉正是我不断选择它的原因。

来源:Hacker News · cm.xyz