Effect v4 升级后有哪些编译器不会报的行为变化?

升级后 tsc 全绿,catch (e) 里的 e instanceof MyError 却从 false 变成了 true,手写的 _tag === "TimeoutException" 比较则悄悄永远不成立。

行为差异共有七处:runPromise 的拒绝值、Option 与 Result 不再是 Effect、超时错误改名、Stream.runCollect 返回数组、解码错误改名、Cause 变扁平、缺失配置的报错信息。其中编译器基本拦不住的,要靠测试。

每一条我都在 effect 3.22.3 与 4.0.5 上各跑了一次,对照确认。先看清单,再看一份能直接跑的测试。改名类的变化见 Effect 从 v3 升级到 v4 要改哪些地方?。

runPromise 失败时拒绝的是什么?

v3 里失败会被包成 FiberFailure,catch (e) 里 e instanceof MyError 是 false。v4 直接拒绝原始的错误对象,instanceof 成立;Effect.die("x") 则直接拒绝字符串 "x"。依赖旧行为的错误处理中间件要重新检查。

Option 和 Result 还能直接 yield* 吗?

不能。v3 里可以在 Effect.gen 里 yield* Option.some(1),yield* Either.left(...) 会让 Effect 失败;v4 里这既是类型错误,运行时也会报 Not a valid effect。显式转换:Effect.fromOption(缺值时以 NoSuchElementError 失败)与 Effect.fromResult。

超时、解码、Stream 和 Cause 变了什么?

  • 超时错误改名。Effect.timeout 的失败从 TimeoutException 变成 Cause.TimeoutError,_tag 同步改为 "TimeoutError"。类型化的 catchTag("TimeoutException") 会直接编译失败,但手写 _tag === "TimeoutException" 比较的地方会静默失效,需要全局搜一遍。
  • Stream.runCollect 返回数组。v3 返回 Chunk,v4 返回普通 Array。所有对结果调用 Chunk.toReadonlyArray 的地方都要改。
  • 解码错误改名。ParseError 变成 SchemaError,同步解码抛出的也是 SchemaError。
  • Cause 的形状。v3 的 Cause 是一棵树(Sequential、Parallel),v4 是扁平的,通过 cause.reasons 拿到一个数组,元素 _tag 是 Fail、Die、Interrupt。

缺失的配置会报什么错?

Config.Redacted("KEY") 缺失时,失败是 ConfigError,里面包着 SchemaError,信息是 Expected string at ["KEY"],变量名只出现在路径里,对运维不算友好,值得自己再包一层。Redacted 本身的用法见 Effect 的 Redacted 怎么防止密钥泄漏到日志?。

怎么用测试把这些行为固定下来?

下面这份测试覆盖了前面的大部分条目:

// behaviors.test.ts
import { it, expect } from "@effect/vitest"
import { Cause, Data, Effect, Fiber, Option, Result, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"

class MyError extends Data.TaggedError("MyError")<{ readonly n: number }> {}

it("runPromise 拒绝原始错误对象", async () => {
  const rejected = await Effect.runPromise(Effect.fail(new MyError({ n: 1 }))).catch((e: unknown) => e)
  expect(rejected instanceof MyError).toBe(true)
})

it.effect("Option 与 Result 需要显式转换", () =>
  Effect.gen(function* () {
    const none = yield* Effect.flip(Effect.fromOption(Option.none<number>()))
    expect(none._tag).toBe("NoSuchElementError")
    const failed = yield* Effect.flip(Effect.fromResult(Result.fail("bad")))
    expect(failed).toBe("bad")
  })
)

it.effect("timeout 失败是 TimeoutError", () =>
  Effect.gen(function* () {
    const fiber = yield* Effect.forkChild(Effect.flip(Effect.timeout(Effect.never, "10 millis")))
    yield* TestClock.adjust("10 millis")
    const error = yield* Fiber.join(fiber)
    expect(error._tag).toBe("TimeoutError")
  })
)

it.effect("runCollect、SchemaError、Cause.reasons", () =>
  Effect.gen(function* () {
    const collected = yield* Stream.runCollect(Stream.make(1, 2, 3))
    expect(Array.isArray(collected)).toBe(true)

    const decodeError = yield* Effect.flip(Schema.decodeUnknownEffect(Schema.Number)("x"))
    expect(decodeError._tag).toBe("SchemaError")

    const cause = Cause.combine(Cause.fail("a"), Cause.die("b"))
    expect(cause.reasons.map((r) => r._tag)).toEqual(["Fail", "Die"])
  })
)
版本:核对在 effect 4.0.5 上进行,每条行为都用 effect 3.22.3 对照过;测试用 @effect/vitest 实际运行。