已发布 上游基线 bf46254 原文 ↗ 在 GitHub 编辑

从 ZIO 转向 Effect

Effect 与 ZIO 之间的关键差异。

如果你是从 ZIO 转向 Effect 的开发者,有一些差异需要留意。

环境

在 Effect 中,我们把运行一段 effect 工作流所需的环境表示为服务的并集(union)

示例(使用服务的并集定义环境)

import { Effect } from "effect"

interface IOError {
  readonly _tag: "IOError"
}

interface HttpError {
  readonly _tag: "HttpError"
}

interface Console {
  readonly log: (msg: string) => void
}

interface Logger {
  readonly log: (msg: string) => void
}

type Response = Record<string, string>

// `R` is a union of `Console` and `Logger`
type Http = Effect.Effect<Response, IOError | HttpError, Console | Logger>

对于从 ZIO 过来的人,这可能有些令人困惑:在 ZIO 中,环境被表示为服务的交集(intersection)

type Http = ZIO[Console with Logger, IOError, Response]

设计缘由

使用并集来表示 Effect 工作流所需环境,其缘由归根结底在于我们希望去掉作为环境中服务包装器的 Has(这与 ZIO 2.0 中达成的目标类似)。

为了能从 Effect 中去掉 Has,考虑到 TypeScript 是结构化类型系统,我们必须更多地从结构的角度来思考。在 TypeScript 中,如果你有一个类型 A & B,而 AB 之间存在结构冲突,那么类型 A & B 就会归约为 never

示例(交叉类型的冲突)

interface A {
  readonly prop: string
}

interface B {
  readonly prop: number
}

// @errors: 2322
const ab: A & B = {
  prop: "",
}

在早期的 Effect 版本中,人们用交叉类型来表示包含多个服务的环境。使用交叉类型(即 A & B)的问题在于:环境中可能存在多个服务,它们拥有同名的方法和属性。为了解决这个问题,我们把服务包装进 Has 类型(类似于 ZIO 1.0),于是你的环境中就会出现 Has<A> & Has<B>

在 ZIO 2.0 中,ZIO 类型(表示环境)的_逆变(contravariant)_ R 类型参数变成了完全幻影(phantom)的,从而可以移除 Has 类型。这显著提升了类型签名的清晰度,也为新用户消除了又一个「绊脚石」。

为了在 Effect 中顺利移除 Has,我们必须考虑环境中的类型如何组合。按照组合规则,就可赋值性而言,以交叉类型(即 &)组合起来的逆变参数,等价于以并集(即 |)组合起来的协变参数。基于这一点,我们决定与 ZIO 分道扬镳,把 R 类型参数设为_协变(covariant)_的,因为当 AB 存在冲突时,A | B 不会归约为 never

沿用上面的例子:

interface A {
  readonly prop: string
}

interface B {
  readonly prop: number
}

// ok
const ab: A | B = {
  prop: "",
}

R 表示为一个协变类型参数,其中承载 Effect 工作流所需的服务的并集,这使我们得以去掉对 Has 的要求。

类型别名

在 Effect 中,不像 ZIO 那样有 UIOURIORIOTaskIO 这类预定义的类型别名。

原因在于,类型别名一旦被组合就会丢失,除非你为每一个函数都维护多套签名,否则它们多少有些无用。在 Effect 中,我们选择不走这条路。相反,我们用 never 类型来表示未被使用的类型。

值得一提的是,「类型别名更容易理解」这种感觉往往只是一种错觉。在 Effect 中,显式的写法 Effect<A> 清楚地表明只使用了类型 A。而使用 RIO<R, A> 这样的类型别名时,就会冒出关于类型 E 的疑问:它是 unknown 吗?还是 never?要记住这类细节并不容易。