【全网首发】Effect:一组用来编写更好的TypeScript的库(一)

151 阅读27分钟

某天摸鱼时日常在某个主包的群里水群时,看到主包提到的一个库:Effect,看了一眼挺有意思的,下面开始了解一下。前排提示,这里打算用ds-v3来翻译官方的文档,不喜欢ai机翻的可以直接去看官方文档了,不喜欢TypeScript的也可以出门左转了。

为什么用Effect

编程充满挑战。当我们构建库和应用时,会借助众多工具来应对复杂性,让日常工作更可控。Effect 为 TypeScript 编程提供了一种全新的思维方式。

Effect 是一个工具生态系统,能帮助你构建更出色的应用程序和库。在这个过程中,你还将深入理解 TypeScript 语言,学会如何运用类型系统使程序更可靠、更易维护。

在"传统"的 TypeScript(不使用 Effect 时)中,我们编写的代码默认函数要么成功执行,要么抛出异常。例如:

const divide = (a: number, b: number): number => {
  if (b === 0) {
    throw new Error("Cannot divide by zero")
  }
  return a / b
}

仅从类型来看,我们无法得知这个函数可能抛出异常,必须阅读代码才能发现。当代码库只有一个函数时这似乎不是问题,但当函数成百上千时,问题就会累积。我们很容易忘记函数可能抛出异常,也容易忽略异常处理。

通常我们会选择"最简单"的做法——用try/catch包裹函数。这虽能防止程序崩溃,却无助于管理或理解复杂的应用/库。我们可以做得更好。

TypeScript 最重要的工具之一就是编译器,它是防范错误、领域问题和复杂性的第一道防线。

Effect 模式

虽然 Effect 是包含众多工具的庞大生态系统,但其核心理念可归结为一点:

Effect 的独特之处在于,我们不仅能通过类型系统追踪​​成功值​​(如上述除法示例),还能追踪​​错误​​和​​上下文​​。

这是采用 Effect 模式重写的除法函数:

import { Effect } from "effect"

const divide = (
  a: number,
  b: number
): Effect.Effect<number, Error, never> =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b)

现在函数不再抛出异常,而是将错误作为值来处理,可以像成功值一样传递。类型签名也清晰表明:

  • 函数返回的成功值类型(number
  • 可能发生的错误(Error
  • 需要的额外上下文或依赖(never表示无)
         ┌─── 生成 number 类型的值
         │       ┌─── 可能失败并返回 Error
         │       │      ┌─── 无需任何依赖
         ▼       ▼      ▼
Effect<number, Error, never>

通过追踪上下文,你无需将所有内容都作为参数传递,就能为函数提供额外信息。例如在测试时,无需修改核心业务逻辑就能用模拟服务替换真实的外部服务实现。

避免重复造轮子

TypeScript 应用代码常常反复解决相同问题:与外部服务交互、文件系统操作、数据库访问等,这些都是开发者面临的共性问题。Effect 提供了丰富的库生态系统,为这些问题提供标准化解决方案。你可以直接使用这些库构建应用,也可以基于它们开发自己的库。

Effect 能系统化地管理错误处理、调试、追踪、异步/Promise、重试机制、流处理、并发控制、缓存、资源管理等诸多挑战。你无需重新发明解决方案,也无需安装大量依赖——Effect 通过统一架构解决了通常需要多个不同 API 的依赖库才能解决的问题。

解决实际问题

Effect 深受 Scala 和 Haskell 等语言的启发,但其核心目标是成为实用的工具包,着力解决 TypeScript 开发者构建应用和库时遇到的真实日常问题。

享受构建与学习的乐趣

学习 Effect 充满乐趣。生态系统中的许多开发者既用它解决日常工作难题,也通过它探索前沿理念,不断拓展 TypeScript 的实用边界。

你不必一次性掌握 Effect 的所有内容,可以从解决当前问题最相关的部分开始。Effect 是模块化工具包,你可以按需选用。但随着代码库中 Effect 的增多,你很可能会想探索更多生态功能!

安装

环境要求

  • TypeScript 5.4 或更高版本
  • 支持 Node.js、Deno 和 Bun 运行时

自动安装(推荐)

使用 create-effect-app 可快速创建配置完整的 Effect 应用,执行以下命令:

npx create-effect-app@latest
pnpm create effect-app@latest
yarn create effect-app@latest
bunx create-effect-app@latest
deno init --npm effect-app@latest

根据提示完成操作后,将自动生成项目目录并安装所有依赖。更多 CLI 使用细节请参阅 创建 Effect 应用 文档。

文档中还有一些Node.js/Deno/Bun/Vite + React的安装方法,这里不详细写了,可以直接看文档

Effect类型

Effect 类型是​​惰性执行​​的工作流或操作的​​不可变​​描述。这意味着创建 Effect 时不会立即执行,而是定义一个可能成功、失败或需要额外上下文才能完成的程序。

其通用形式如下:

         ┌─── 成功返回值的类型
         │        ┌─── 失败错误的类型
         │        │      ┌─── 所需依赖的类型
         ▼        ▼      ▼
Effect<Success, Error, Requirements>

该类型表示一个 Effect:

  • 成功时返回 Success 类型的值
  • 失败时返回 Error 类型的错误
  • 执行可能需要 Requirements 类型的上下文依赖

概念上,可将 Effect 视为以下函数类型的增强版:

type Effect<Success, Error, Requirements> = (
  context: Context<Requirements>
) => Error | Success

但 Effect 并非实际函数,它能建模同步、异步、并发和资源密集型计算。

​不可变性​​:Effect 值不可变,Effect 库中每个函数都会生成新的 Effect 值。

​交互建模​​:这些值本身不执行任何操作,仅对具有副作用的交互进行建模描述。

​执行机制​​:可通过 Effect 运行时系统 将 Effect 解释为实际的外部交互。理想情况下,执行发生在应用的单一入口点(如主函数)。

类型参数

Effect 包含三个类型参数:

参数说明
​Success​表示 Effect 执行成功时返回值的类型。若为 void 表示不产生有效信息,若为 never 表示会永久运行(直至失败)。
​Error​表示 Effect 执行时可能发生的预期错误类型。若为 never 表示不可能失败(因为不存在 never 类型的值)。
​Requirements​表示 Effect 执行所需的上下文数据类型,存储在 Context 集合中。若为 never 表示无依赖要求且 Context 集合为空。

类型参数缩写

Effect 生态中常见 AER 缩写,分别对应成功值(​​A​​)、错误(​​E​​rror)和依赖要求(​​R​​equirements)。

类型提取

通过 Effect.SuccessEffect.Error 和 Effect.Context 工具类型可从 Effect 中提取对应类型。

​示例​​(提取成功值、错误和上下文类型)

import { Effect, Context } from "effect"

class SomeContext extends Context.Tag("SomeContext")<SomeContext, {}>() {}

// 假设有个返回数字、可能报错、需要 SomeContext 的 Effect
declare const program: Effect.Effect<number, Error, SomeContext>

// 提取成功类型(number)
type A = Effect.Effect.Success<typeof program>

// 提取错误类型(Error)
type E = Effect.Effect.Error<typeof program>

// 提取上下文类型(SomeContext)
type R = Effect.Effect.Context<typeof program>

创建Effect

Effect 提供了多种创建效果(Effect)的方式,这些效果是封装副作用计算的基本单元。以下是常用的创建方法:

为什么不直接抛出错误?

传统编程中通常通过抛出异常处理错误:

// 类型签名未体现可能抛出的异常
const divide = (a: number, b: number): number => {
  if (b === 0) {
    throw new Error("Cannot divide by zero")
  }
  return a / b
}

但这种方式存在缺陷:函数类型签名无法体现可能抛出的异常,导致潜在错误难以追踪。为此,Effect 引入了 Effect.succeed 和 Effect.fail 构造器,通过类型系统显式追踪错误。

succeed

创建始终返回指定值的成功效果:

import { Effect } from "effect"

//      ┌─── Effect<number, never, never>
//      ▼
const success = Effect.succeed(42)

类型 Effect<number, never, never> 表示:

  • 成功返回 number 类型值
  • 不会产生错误(never
  • 无需额外依赖(never

fail

创建可恢复的失败效果:

import { Effect } from "effect"

//      ┌─── Effect<never, Error, never>
//      ▼
const failure = Effect.fail(new Error("网络错误"))

类型 Effect<never, Error, never> 表示:

  • 不会产生成功值(never
  • 可能抛出 Error 类型错误
  • 无需额外依赖(never

支持使用带 _tag 字段的标记错误:

import { Effect } from "effect"

class HttpError {
  readonly _tag = "HttpError"
}

//      ┌─── Effect<never, HttpError, never>
//      ▼
const program = Effect.fail(new HttpError())

错误追踪

通过显式错误处理改写除法函数:

import { Effect } from "effect"

const divide = (a: number, b: number): Effect.Effect<number, Error> =>
  b === 0
    ? Effect.fail(new Error("除零错误"))
    : Effect.succeed(a / b)

类型 Effect<number, Error> 明确表达了可能的成功与失败结果。

同步效果建模

在 JavaScript 中,您可以使用"thunk"来延迟同步计算的执行。

Thunk(延迟计算函数)

"thunk"是指一个无参数、可能返回某个值的函数。

这种模式的优势在于可以推迟值的计算,直到真正需要时才执行。

为了处理同步副作用,Effect 提供了两个构造函数:

sync

创建一个表示​​同步副作用计算​​的Effect

当您确定操作​​不会失败​​时使用Effect.sync

注意事项:

  • 传入的函数(thunk)​​禁止抛出错误​​,若抛出将被视为「缺陷」
  • 这类缺陷不属于常规错误,表示存在预期外的逻辑漏洞
  • 可通过Effect.catchAllDefect捕获和记录
  • 该机制确保所有意外故障都能被追踪处理
import { Effect } from "effect"

const log = (message: string) =>
  Effect.sync(() => {
    console.log(message) // 副作用
  })

//      ┌─── Effect<void, never, never>
//      ▼
const program = log("Hello, World!")

特点:

  • 副作用不会立即执行
  • 通过显式运行控制触发时机(参见运行效果)
  • 提升大型应用中副作用的管理性和可预测性

try

创建一个表示可能失败的同步计算的 Effect

当你需要执行可能失败的同步操作(例如解析 JSON)时,可以使用 Effect.try 构造函数。该构造函数旨在通过捕获这些异常并将其转换为可管理的错误来处理可能抛出异常的操作。

​示例​​(安全的 JSON 解析)

假设你有一个尝试解析 JSON 字符串的函数。如果输入字符串的格式不是有效的 JSON,此操作可能会失败并抛出错误:

import { Effect } from "effect"

const parse = (input: string) =>
  // 如果输入不是有效的 JSON,可能会抛出错误
  Effect.try(() => JSON.parse(input))

//      ┌─── Effect<any, UnknownException, never>
//      ▼
const program = parse("")

在这个例子中:

  • parse 是一个创建封装 JSON 解析操作的 effect 的函数。
  • 如果 JSON.parse(input) 由于无效输入而抛出错误,Effect.try 会捕获此错误,并且由 program 表示的 effect 将以 UnknownException 失败。这确保了错误不会被静默忽略,而是在 effect 的结构化流程中得到处理。
import { Effect } from "effect"

const parse = (input: string) =>
  Effect.try({
    try: () => JSON.parse(input),
    catch: (err) => new Error(`解析失败: ${err}`)
  })

//      ┌─── Effect<any, Error, never>
//      ▼
const program = parse("")

核心机制:

  1. JSON.parse抛出错误时
  2. 自动捕获并转换为UnknownException
  3. 通过效果系统结构化处理错误(而非静默失败)
自定义错误处理

您可能希望将捕获的异常转换为更具体的错误,或在捕获错误时执行其他操作。Effect.try 支持一个重载,允许您指定应如何转换捕获的异常:

​示例​​(自定义错误处理)

import { Effect } from "effect"

const parse = (input: string) =>
  Effect.try({
    // JSON.parse 可能会因输入错误而抛出异常
    try: () => JSON.parse(input),
    // 重新映射错误
    catch: (unknown) => new Error(`出错了 ${unknown}`)
  })

//      ┌─── Effect<any, Error, never>
//      ▼
const program = parse("")

您可以将其视为类似于 JavaScript 中传统的 try-catch 块的模式:

try {
  return JSON.parse(input)
} catch (unknown) {
  throw new Error(`出错了 ${unknown}`)
}

异步效果建模

在传统编程中,我们常用Promise处理异步计算。但Promise的错误处理存在缺陷——默认情况下Promise<Value>仅提供成功值的类型Value,错误类型未被纳入类型系统。这限制了表达能力,使得错误处理与追踪变得困难。

为解决这些问题,Effect提供了专门构造器来显式表示异步上下文中的成功与失败:Effect.promiseEffect.tryPromise。这些构造器允许您​​利用类型系统追踪错误​​,同时明确处理成功与失败场景。

promise

创建表示必定成功的异步计算的Effect

当确定操作不会拒绝时使用Effect.promise

提供的函数(thunk)应返回永不拒绝的Promise;若意外拒绝,错误将被视为"缺陷"

这类缺陷不属于常规错误,而是表明本应无错的逻辑存在漏洞。可将其类比为程序意外崩溃,可通过Effect.catchAllDefect等工具进一步管理或记录。该特性确保应用中的意外故障也不会丢失。

​示例​​ (延迟消息)

import { Effect } from "effect"

const delay = (message: string) =>
  Effect.promise<string>(() => 
    new Promise(resolve => 
      setTimeout(() => resolve(message), 2000)
    )
  )

//      ┌─── Effect<string, never, never>
//      ▼
const program = delay("操作成功!")

program的类型为Effect<string, never, never>,可解读为:

  • 产出string类型的成功值
  • 不产生预期错误(never)
  • 不需要任何上下文(never)

tryPromise

创建可能失败的异步计算的Effect

Effect.promise不同,当底层Promise可能拒绝时使用此构造器。默认情况下错误会被捕获,并以UnknownException类型传递至错误通道。

​示例​​ (获取TODO条目)

import { Effect } from "effect"

const getTodo = (id: number) =>
  // 捕获所有错误并以UnknownException传播
  Effect.tryPromise(() =>
    fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
  )

//      ┌─── Effect<Response, UnknownException, never>
//      ▼
const program = getTodo(1)

program的类型为Effect<Response, UnknownException, never>,可解读为:

  • 产出Response类型的成功值
  • 可能产生UnknownException错误
  • 不需要任何上下文(never)
自定义错误处理

若需更精细控制错误传递,可使用带重映射函数的Effect.tryPromise重载:

​示例​​ (自定义错误处理)

import { Effect } from "effect"

const getTodo = (id: number) =>
  Effect.tryPromise({
    try: () => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`),
    // 错误重映射
    catch: (unknown) => new Error(`操作失败:${unknown}`)
  })

//      ┌─── Effect<Response, Error, never>
//      ▼
const program = getTodo(1)

回调函数封装

将基于回调的异步函数转换为Effect

处理不支持async/awaitPromise的回调式API时,可使用Effect.async构造器。

​示例​​ (封装回调API)

将Node.jsfs模块的readFile函数封装为Effect API(需安装@types/node):

import { Effect } from "effect"
import * as NodeFS from "node:fs"

const readFile = (filename: string) =>
  Effect.async<Buffer, Error>((resume) => {
    NodeFS.readFile(filename, (error, data) => {
      if (error) {
        // 出错时恢复为失败Effect
        resume(Effect.fail(error))
      } else {
        // 成功时恢复为成功Effect
        resume(Effect.succeed(data))
      }
    })
  })

//      ┌─── Effect<Buffer, Error, never>
//      ▼
const program = readFile("example.txt")

上例中我们显式标注类型参数:

Effect.async<Buffer, Error>((resume) => {
  // ...
})

因为TypeScript无法根据回调体内的返回值推断类型。类型标注确保传给resume的值符合预期。

Effect.async中的resume函数应仅调用一次,多余调用会被忽略。

​示例​​ (忽略多余resume调用)

import { Effect } from "effect"

const program = Effect.async<number>((resume) => {
  resume(Effect.succeed(1))
  resume(Effect.succeed(2)) // 此行被忽略
})

// 运行程序
Effect.runPromise(program).then(console.log) // 输出: 1

支持中断清理:

import { Effect, Fiber } from "effect"
import * as NodeFS from "node:fs"

const writeFile = (filename: string, data: string) =>
  Effect.async<void, Error>((resume) => {
    const stream = NodeFS.createWriteStream(filename)
    stream.write(data)
    stream.on("finish", () => resume(Effect.void))
    stream.on("error", (err) => resume(Effect.fail(err)))
    return Effect.sync(() => NodeFS.unlinkSync(filename))
  })

const program = Effect.gen(function* () {
  const fiber = yield* Effect.fork(writeFile("test.txt", "data"))
  yield* Effect.sleep(1000)
  yield* Fiber.interrupt(fiber)
})

高级用法

对于高级场景,resume可返回一个Effect,当运行该效应的纤程被中断时执行。这在需要处理操作中断时的资源清理时特别有用。

​示例​​ (带清理的中断处理)

本例中:

  • writeFileWithCleanup函数向文件写入数据
  • 若运行该效应的纤程被中断,则执行清理Effect(删除文件)
  • 确保操作取消时正确清理文件句柄等资源
import { Effect, Fiber } from "effect"
import * as NodeFS from "node:fs"

// 模拟长时间文件写入操作
const writeFileWithCleanup = (filename: string, data: string) =>
  Effect.async<void, Error>((resume) => {
    const writeStream = NodeFS.createWriteStream(filename)

    // 开始写入数据
    writeStream.write(data)

    // 写入完成时恢复成功
    writeStream.on("finish", () => resume(Effect.void))

    // 写入出错时恢复失败
    writeStream.on("error", (err) => resume(Effect.fail(err)))

    // 通过返回清理Effect处理中断
    return Effect.sync(() => {
      console.log(`清理文件 ${filename}`)
      NodeFS.unlinkSync(filename)
    })
  })

const program = Effect.gen(function* () {
  const fiber = yield* Effect.fork(
    writeFileWithCleanup("example.txt", "长数据...")
  )
  // 模拟1秒后中断纤程
  yield* Effect.sleep("1 second")
  yield* Fiber.interrupt(fiber) // 触发清理
})

// 运行程序
Effect.runPromise(program)
/*
输出:
清理文件 example.txt
*/

若封装的操作支持中断,resume函数可接收AbortSignal直接处理中断请求。

​示例​​ (使用AbortSignal处理中断)

import { Effect, Fiber } from "effect"

// 支持通过AbortSignal中断的任务
const interruptibleTask = Effect.async<void, Error>((resume, signal) => {
  // 处理中断
  signal.addEventListener("abort", () => {
    console.log("收到中止信号")
    clearTimeout(timeoutId)
  })

  // 模拟长时间任务
  const timeoutId = setTimeout(() => {
    console.log("操作完成")
    resume(Effect.void)
  }, 2000)
})

const program = Effect.gen(function* () {
  const fiber = yield* Effect.fork(interruptibleTask)
  // 模拟1秒后中断纤程
  yield* Effect.sleep("1 second")
  yield* Fiber.interrupt(fiber)
})

// 运行程序
Effect.runPromise(program)
/*
输出:
收到中止信号
*/

延迟效果创建

Effect.suspend用于延迟创建效果,将效应评估推迟到实际需要时执行。该函数接收表示效应的thunk,将其包装为延迟效果。

语法​

const suspendedEffect = Effect.suspend(() => effect)

以下是Effect.suspend的典型应用场景。

惰性求值

当需要推迟效应执行时(特别是对高开销计算或非必需操作)。对于包含副作用或作用域捕获的效应,使用Effect.suspend确保每次调用都重新执行。

​示例​​ (带副作用的惰性求值)

import { Effect } from "effect"

let i = 0

const bad = Effect.succeed(i++)

const good = Effect.suspend(() => Effect.succeed(i++))

console.log(Effect.runSync(bad)) // 输出: 0
console.log(Effect.runSync(bad)) // 输出: 0

console.log(Effect.runSync(good)) // 输出: 1
console.log(Effect.runSync(good)) // 输出: 2

Running Effect

本例使用Effect.runSync执行效应并显示结果(详见Running Effect)。

此例中,bad是单次调用Effect.succeed(i++)的结果(虽然递增了作用域变量但返回原始值)。Effect.runSync(bad)不会触发新计算,因为Effect.succeed(i++)已完成求值。而每次Effect.runSync(good)都会执行Effect.suspend()中的thunk,输出变量的最新值。

处理循环依赖

Effect.suspend可管理效应间的循环依赖(如相互依赖的效应)。在递归函数中常用Effect.suspend来避免立即求值。

​示例​​ (递归斐波那契)

import { Effect } from "effect"

const blowsUp = (n: number): Effect.Effect<number> =>
  n < 2
    ? Effect.succeed(1)
    : Effect.zipWith(blowsUp(n - 1), blowsUp(n - 2), (a, b) => a + b)

// console.log(Effect.runSync(blowsUp(32)))
// 崩溃: JavaScript堆内存不足

const allGood = (n: number): Effect.Effect<number> =>
  n < 2
    ? Effect.succeed(1)
    : Effect.zipWith(
        Effect.suspend(() => allGood(n - 1)),
        Effect.suspend(() => allGood(n - 2)),
        (a, b) => a + b
      )

console.log(Effect.runSync(allGood(32))) // 输出: 3524578

本例使用Effect.zipWith组合两个效应的结果(详见zipping)。

blowsUp函数直接递归计算斐波那契数列,每次调用都会立即触发更多递归调用,快速耗尽JavaScript调用栈。

allGood通过Effect.suspend延迟递归调用,避免栈溢出。这种机制不会立即执行递归效应,而是安排稍后执行,从而保持调用栈浅层。

统一返回类型

当TypeScript难以推断返回的效应类型时,可使用Effect.suspend解决类型问题。

​示例​​ (使用Effect.suspend辅助类型推断)

import { Effect } from "effect"

/*
  不使用suspend时TypeScript可能推断失败。

  推断类型:
    (a: number, b: number) =>
      Effect<never, Error, never> | Effect<number, never, never>
*/
const withoutSuspend = (a: number, b: number) =>
  b === 0
    ? Effect.fail(new Error("除零错误"))
    : Effect.succeed(a / b)

/*
  使用suspend统一返回类型。

  推断类型:
    (a: number, b: number) => Effect<number, Error, never>
*/
const withSuspend = (a: number, b: number) =>
  Effect.suspend(() =>
    b === 0
      ? Effect.fail(new Error("除零错误"))
      : Effect.succeed(a / b)
  )

速查表

API输入输出类型
succeedAEffect<A>
failEEffect<never, E>
sync() => AEffect<A>
try() => AEffect<A, UnknownException>
promise() => Promise<A>Effect<A>
tryPromise() => Promise<A>Effect<A, Error>
async(Effect => void) => voidEffect<A, E>
suspend() => Effect<A, E, R>Effect<A, E, R>

完整构造器列表参考 Effect 构造器文档

执行效果

要执行一个效果(effect),可以使用 Effect 模块提供的多种 run 函数之一。

在程序的边缘运行Effects

推荐的做法是将程序的主要逻辑设计为Effects。建议在程序"边界"附近使用 run* 函数。这种方法可以更灵活地执行程序并构建复杂的效果。

runSync

示例​​(同步日志记录)

import { Effect } from "effect"

const program = Effect.sync(() => {
  console.log("Hello, World!")
  return 1
})

const result = Effect.runSync(program)
// 输出: Hello, World!

console.log(result)
// 输出: 1

注意:

  • 仅适用于不会失败且不含异步操作的效果
  • 若效果失败或包含异步操作,将抛出错误

示例​​(错误用法:失败或异步效果)

import { Effect } from "effect"

try {
  // 尝试运行一个失败的效果
  Effect.runSync(Effect.fail("my error"))
} catch (e) {
  console.error(e)
}
/*
输出:
(FiberFailure) Error: my error
*/

try {
  // 尝试运行一个涉及异步工作的效果
  Effect.runSync(Effect.promise(() => Promise.resolve(1)))
} catch (e) {
  console.error(e)
}
/*
输出:
(FiberFailure) AsyncFiberException: Fiber #0 cannot be resolved synchronously. This is caused by using runSync on an effect that performs async work
*/

runSyncExit

同步运行一个效果,并将结果作为 Exit 类型返回,该类型表示效果的成功或失败结果。

使用 Effect.runSyncExit 来确定效果是否成功或失败(包括任何缺陷),而无需处理异步操作。

Exit 类型表示效果的结果:

  • 如果效果成功,结果会被包装在 Success 中。
  • 如果失败,失败信息会作为 Failure 提供,其中包含 Cause 类型。

​示例​​(以 Exit 形式处理结果)

import { Effect } from "effect"

console.log(Effect.runSyncExit(Effect.succeed(1)))
/*
输出:
{ _tag: "Success", value: 1 }
*/

console.log(Effect.runSyncExit(Effect.fail("错误")))
/*
输出:
{ 
  _tag: "Failure", 
  cause: { _tag: "Fail", failure: "错误" }
}
*/

如果效果包含异步操作,Effect.runSyncExit 将返回一个带有 Die 原因的 Failure,表示效果无法同步解析。

​示例​​(异步操作导致 Die)

import { Effect } from "effect"

console.log(Effect.runSyncExit(Effect.promise(() => Promise.resolve(1))))
/*
输出:
{
  _tag: 'Failure',
  cause: {
    _tag: 'Die',
    defect: [AsyncFiberException]
  }
}
*/

runPromise

执行一个效果,并将结果作为 Promise 返回。

当你需要执行一个效果并使用 Promise 语法处理结果时(通常是为了与其他基于 Promise 的代码兼容),可以使用 Effect.runPromise

​示例​​(将成功效果作为 Promise 运行)

import { Effect } from "effect"

Effect.runPromise(Effect.succeed(1)).then(console.log)
// 输出: 1

如果效果成功,Promise 将以结果解析。如果效果失败,Promise 将以错误拒绝。

​示例​​(处理失败效果作为拒绝的 Promise)

import { Effect } from "effect"

Effect.runPromise(Effect.fail("my error")).catch(console.error)
/*
输出:
(FiberFailure) Error: my error
*/

runPromiseExit

运行一个效果,并返回一个解析为 Exit 的 Promise,该类型表示效果的成功或失败结果。

当你需要确定效果是否成功或失败(包括任何缺陷),并且希望使用 Promise 时,可以使用 Effect.runPromiseExit

Exit 类型表示效果的结果:

  • 如果效果成功,结果会被包装在 Success 中。
  • 如果失败,失败信息会作为 Failure 提供,其中包含 Cause 类型。

​示例​​(以 Exit 形式处理结果)

import { Effect } from "effect"

Effect.runPromiseExit(Effect.succeed(1)).then(console.log)
/*
输出:
{ _tag: "Success", value: 1 }
*/

Effect.runPromiseExit(Effect.fail("错误")).then(console.log)
/*
输出:
{ 
  _tag: "Failure",
  cause: { _tag: "Fail", failure: "错误" }
}
*/

runFork

运行效果的基础函数,返回一个可观察或中断的"fiber"。

Effect.runFork 用于通过创建 fiber 在后台运行效果。它是所有其他运行函数的基础函数。它会启动一个可观察或中断的 fiber。

效果执行的默认值

除非你特别需要 Promise 或同步操作,否则 Effect.runFork 是一个很好的默认选择。

​示例​​(在后台运行效果)

import { Effect, Console, Schedule, Fiber } from "effect"

//      ┌─── Effect<number, never, never>
//      ▼
const program = Effect.repeat(
  Console.log("running..."),
  Schedule.spaced("200 millis")
)

//      ┌─── RuntimeFiber<number, never>
//      ▼
const fiber = Effect.runFork(program)

setTimeout(() => {
  Effect.runFork(Fiber.interrupt(fiber))
}, 500)

在这个示例中,program 会以每次间隔 200 毫秒的方式持续输出 "running..."。你可以在我们的 调度介绍 指南中了解更多关于重复和调度的内容。

为了停止程序的执行,我们对 Effect.runFork 返回的 fiber 使用 Fiber.interrupt。这允许你控制执行流程并在必要时终止它。

要更深入地了解 fiber 的工作原理以及如何处理中断,请查看我们的 Fibers 和 中断 指南。

同步与异步效果

在 Effect 库中,没有内置的方法可以预先确定一个效果是同步执行还是异步执行。虽然这个想法在 Effect 的早期版本中考虑过,但最终没有实现,原因如下:

  1. ​复杂性:​​ 在类型系统中引入这种跟踪同步/异步行为的功能会使 Effect 的使用更加复杂,并限制其可组合性。
  2. ​安全性问题:​​ 我们尝试了不同的方法来跟踪异步效果,但它们都导致了更差的开发体验,而没有显著提高安全性。即使使用完全同步的类型,我们也需要支持 fromCallback 组合器来与使用 Continuation-Passing Style (CPS) 的 API 一起工作。然而,在类型层面上,无法保证这样的函数总是被立即调用而不是延迟调用。

运行效果的最佳实践

在大多数情况下,效果会在应用程序的最外层运行。通常,围绕 Effect 构建的应用程序会涉及对主效果的单个调用。以下是运行效果的方法:

  • 使用 runPromise 或 runFork:在大多数情况下,异步执行应该是默认选择。这些方法提供了处理基于效果的工作流的最佳方式。
  • 仅在必要时使用 runSync:同步执行应被视为边缘情况,仅在异步执行不可行时使用。例如,当你确定效果是纯同步的并且需要立即结果时。

速查表

API输入输出
runSyncEffect<A, E>A
runSyncExitEffect<A, E>Exit<A, E>
runPromiseEffect<A, E>Promise<A>
runPromiseExitEffect<A, E>Promise<Exit<A, E>>
runForkEffect<A, E>RuntimeFiber<A, E>

完整执行方法列表参见 效果执行文档

使用生成器

Effect 提供了一种类似 async/await 的便捷语法,通过 生成器函数 来编写包含副作用的效果代码。

可选特性

生成器语法是 Effect 的可选特性。若不熟悉生成器,可参考 构建管道 文档了解其他编码风格。

理解 Effect.gen

Effect.gen 工具通过 JavaScript 生成器函数简化效果代码编写,使代码更接近传统同步写法,提升可读性和错误处理能力。

​示例​​(带折扣的交易处理):

import { Effect } from "effect"

// 为交易金额添加小额服务费
const addServiceCharge = (amount: number) => amount + 1

// 安全应用折扣到交易金额
const applyDiscount = (
  total: number,
  discountRate: number
): Effect.Effect<number, Error> =>
  discountRate === 0
    ? Effect.fail(new Error("折扣率不能为零"))
    : Effect.succeed(total - (total * discountRate) / 100)

// 模拟从数据库异步获取交易金额
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

// 模拟从配置文件异步获取折扣率
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))

// 使用生成器函数组装程序
const program = Effect.gen(function* () {
  // 获取交易金额
  const transactionAmount = yield* fetchTransactionAmount

  // 获取折扣率
  const discountRate = yield* fetchDiscountRate

  // 计算折扣后金额
  const discountedAmount = yield* applyDiscount(
    transactionAmount,
    discountRate
  )

  // 应用服务费
  const finalAmount = addServiceCharge(discountedAmount)

  // 返回最终应收金额
  return `最终应收金额:${finalAmount}`
})

// 执行程序并记录结果
Effect.runPromise(program).then(console.log)
// 输出:最终应收金额:96

使用 Effect.gen 的关键步骤:

  • 用 Effect.gen 包裹逻辑
  • 使用 yield* 处理效果
  • 返回最终结果

需要的TypeScript配置

需在 tsconfig.json 中设置 "downlevelIteration": true"target": "es2015" 及以上版本。

与 async/await 对比

与 async/await 写法相似但不等同:

// 使用Effect.gen
import { Effect } from "effect"

const addServiceCharge = (amount: number) => amount + 1

const applyDiscount = (
  total: number,
  discountRate: number
): Effect.Effect<number, Error> =>
  discountRate === 0
    ? Effect.fail(new Error("折扣率不能为零"))
    : Effect.succeed(total - (total * discountRate) / 100)

const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))

export const program = Effect.gen(function* () {
  const transactionAmount = yield* fetchTransactionAmount
  const discountRate = yield* fetchDiscountRate
  const discountedAmount = yield* applyDiscount(
    transactionAmount,
    discountRate
  )
  const finalAmount = addServiceCharge(discountedAmount)
  return `最终应收金额:${finalAmount}`
})
// 使用await
const addServiceCharge = (amount: number) => amount + 1

const applyDiscount = (
  total: number,
  discountRate: number
): Promise<number> =>
  discountRate === 0
    ? Promise.reject(new Error("折扣率不能为零"))
    : Promise.resolve(total - (total * discountRate) / 100)

const fetchTransactionAmount = Promise.resolve(100)

const fetchDiscountRate = Promise.resolve(5)

export const program = async function () {
  const transactionAmount = await fetchTransactionAmount
  const discountRate = await fetchDiscountRate
  const discountedAmount = await applyDiscount(
    transactionAmount,
    discountRate
  )
  const finalAmount = addServiceCharge(discountedAmount)
  return `最终应收金额:${finalAmount}`
}

需要注意的是,尽管代码看起来相似,但这两个程序并不完全相同。将它们并列比较只是为了突出它们在写法上的相似性。

控制流支持

使用 Effect.gen 与生成器的重要优势之一是可以在生成器函数中使用标准控制流结构。包括 if/elseforwhile 等分支和循环机制,增强了代码中复杂控制流逻辑的表达能力。

​示例​​(使用控制流)

import { Effect } from "effect"

const calculateTax = (
  amount: number,
  taxRate: number
): Effect.Effect<number, Error> =>
  taxRate > 0
    ? Effect.succeed((amount * taxRate) / 100)
    : Effect.fail(new Error("无效税率"))

const program = Effect.gen(function* () {
  let i = 1

  while (true) {
    if (i === 10) {
      break // 当计数器达到10时跳出循环
    } else {
      if (i % 2 === 0) {
        // 为偶数计算税款
        console.log(yield* calculateTax(100, i))
      }
      i++
      continue
    }
  }
})

Effect.runPromise(program)
/*
输出:
2
4
6
8
*/

如何抛出错误

Effect.gen API 允许通过生成失败的副作用直接将错误处理集成到工作流中。可以使用 Effect.fail 抛出错误,如下例所示:

​示例​​(在流程中引入错误)

import { Effect, Console } from "effect"

const task1 = Console.log("任务1...")
const task2 = Console.log("任务2...")

const program = Effect.gen(function* () {
  // 执行一些任务
  yield* task1
  yield* task2
  // 抛出错误
  yield* Effect.fail("出错了!")
})

Effect.runPromise(program).then(console.log, console.error)
/*
输出:
任务1...
任务2...
(FiberFailure) Error: 出错了!
*/

短路特性

使用 Effect.gen 时,理解其错误处理机制非常重要。该 API 会在遇到​​第一个错误​​时停止执行并返回该错误。

这对代码有什么影响?如果有多个顺序操作,当任何一个失败时,剩余操作将不会执行,错误会被立即返回。

简而言之,任何环节出错都会立即终止程序并返回错误。

​示例​​(在第一个错误处终止执行)

import { Effect, Console } from "effect"

const task1 = Console.log("任务1...")
const task2 = Console.log("任务2...")
const failure = Effect.fail("出错了!")
const task4 = Console.log("任务4...")

const program = Effect.gen(function* () {
  yield* task1
  yield* task2
  // 程序因错误在此终止
  yield* failure
  // 后续代码不会执行
  yield* task4
  return "某个结果"
})

Effect.runPromise(program).then(console.log, console.error)
/*
输出:
任务1...
任务2...
(FiberFailure) Error: 出错了!
*/

即使在出错后代码永远不会执行,除非显式返回,TypeScript 仍可能认为错误后的代码是可到达的。

例如在需要缩小变量类型范围的场景:

​示例​​(未显式返回的类型收窄)

类型收窄需显式返回:

import { Effect } from "effect"

type User = {
  readonly name: string
}

// 假设该函数查询数据库或外部服务
declare function getUserById(id: string): Effect.Effect<User | undefined>

function greetUser(id: string) {
  return Effect.gen(function* () {
    const user = yield* getUserById(id)

    if (user === undefined) {
      // 即使在此失败,TypeScript 仍认为后续可能为 undefined
      yield* Effect.fail(`找不到ID为 ${id} 的用户`)
    }

// Error ts(18048) ― 'user' is possibly 'undefined'.
    return `你好,${user.name}!`
  })
}

更多错误处理技巧参考 错误管理 章节。

传递 this 上下文

在某些情况下,你可能需要将当前对象 (this) 的引用传递到生成器函数体内。你可以通过使用一个接受该引用作为第一个参数的重载来实现:

​示例​​ (向生成器传递 this)

import { Effect } from "effect"

class MyClass {
  readonly local = 1
  compute = Effect.gen(this, function* () {
    const n = this.local + 1

    yield* Effect.log(`计算值: ${n}`)

    return n
  })
}

Effect.runPromise(new MyClass().compute).then(console.log)
/*
输出:
timestamp=... level=INFO fiber=#0 message="计算值: 2"
2
*/

适配器语法(遗弃语法)

你可能仍会遇到一些使用适配器的代码片段,通常以 _ 或 $ 符号表示。

在 TypeScript 的早期版本中,生成器"适配器"函数对于确保生成器内正确的类型推断是必要的。该适配器用于促进 TypeScript 类型系统与生成器函数之间的交互。

​示例​​ (旧代码中的适配器)

import { Effect } from "effect"

const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

// 旧用法:使用适配器进行正确的类型推断
const programWithAdapter = Effect.gen(function* ($) {
  const transactionAmount = yield* $(fetchTransactionAmount)
})

// 当前用法:无需适配器
const program = Effect.gen(function* () {
  const transactionAmount = yield* fetchTransactionAmount
})

随着 TypeScript (v5.5+) 的进步,适配器对于类型推断已不再必要。虽然为了向后兼容它仍存在于代码库中,但预计将在 Effect 的下一个主要版本中移除。

构建管道

效果管道允许对值进行操作的组合和顺序执行,使得数据能以简洁模块化的方式进行转换和处理。

为何管道适合构建应用结构

管道是组织应用程序和处理数据转换的绝佳方式,具有以下优势:

  1. ​可读性​​:管道以清晰顺序组合函数,数据流向和操作一目了然,便于理解和维护代码。
  2. ​代码组织​​:将复杂操作拆分为小型函数,每个函数专注单一任务,提升代码模块化程度。
  3. ​可复用性​​:通过函数拆分,可在不同管道或上下文中复用函数,减少代码重复。
  4. ​类型安全​​:利用类型系统在编译时捕获错误,管道中函数具有明确定义的输入输出类型,确保数据正确流动。

函数 vs 方法

在Effect生态库中使用函数对于实现​​摇树优化​​和确保​​可扩展性​​至关重要。函数能通过消除未使用代码实现高效打包,并为扩展库功能提供灵活模块化方案。

摇树优化

摇树优化指构建系统在打包过程中移除未使用代码的能力。函数可被摇树优化,而方法不行。使用函数时,只有实际导入的函数会被包含在最终打包代码中,未使用的函数会被自动移除,从而减小打包体积提升性能。而方法附着在对象或原型上,无法被轻易摇树优化。

可扩展性

使用函数的另一优势是易于扩展。扩展方法功能时,函数只需定义普通函数即可,无需修改对象原型,这使得代码更清晰模块化,也更好兼容其他库和模块。

pipe 函数

pipe函数让我们以可读的串行方式组合函数,将一个函数的输出作为下一个函数的输入,通过链式调用实现复杂转换。

​语法​

const result = pipe(input, func1, func2, ..., funcN)

其中input是初始值,func1funcN是依次应用的函数。每个函数的结果作为下一个函数的输入,最终返回结果。

​示例​​(算术运算链)

const increment = (x) => x + 1
const double = (x) => x * 2
const result = pipe(5, increment, double) // 输出12

这等价于double(increment(5)),但pipe使代码从左到右顺序执行,更易阅读。

map

通过对效果内部值应用函数来转换值。

​语法​

const mappedEffect = pipe(myEffect, Effect.map(transformation))
// 或
const mappedEffect = Effect.map(myEffect, transformation)
// 或
const mappedEffect = myEffect.pipe(Effect.map(transformation))

Effect.map 接受一个函数,并将其应用于效果内部包含的值,从而创建一个带有转换后值的新效果。

效果是不可变的

需要注意的是,效果是不可变的,这意味着原始效果不会被修改。相反,会返回一个带有更新值的新效果。

​示例​​(添加服务费)

这是一个实际示例,我们对交易金额应用服务费:

import { pipe, Effect } from "effect"

// 为交易金额添加小额服务费的函数
const addServiceCharge = (amount: number) => amount + 1

// 模拟从数据库异步获取交易金额的任务
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

// 对交易金额应用服务费
const finalAmount = pipe(
  fetchTransactionAmount,
  Effect.map(addServiceCharge)
)

Effect.runPromise(finalAmount).then(console.log) // 输出: 101

as

替换效果内的值为常量:

import { pipe, Effect } from "effect"

// 将值 5 替换为常量 "new value"
const program = pipe(Effect.succeed(5), Effect.as("new value"))

Effect.runPromise(program).then(console.log) // 输出: "new value"

flatMap

链接效果以产生新的 Effect 实例,适用于组合依赖于先前结果的操作。

​语法​

const flatMappedEffect = pipe(myEffect, Effect.flatMap(transformation))
// 或者
const flatMappedEffect = Effect.flatMap(myEffect, transformation)
// 或者
const flatMappedEffect = myEffect.pipe(Effect.flatMap(transformation))

在上述代码中,transformation 是一个函数,它接受一个值并返回一个 Effect,而 myEffect 是要被转换的初始 Effect

当你需要链接多个效果时,使用 Effect.flatMap,确保每个步骤都产生一个新的 Effect,同时展平可能出现的任何嵌套效果。

它类似于与数组一起使用的 flatMap,但专门用于 Effect 实例,使你可以避免深度嵌套的效果结构。

需要注意的是,效果是不可变的,这意味着原始效果不会被修改。相反,会返回一个带有更新值的新效果。

​示例​​(应用折扣)

import { pipe, Effect } from "effect"

// 安全地将折扣应用于交易金额的函数
const applyDiscount = (
  total: number,
  discountRate: number
): Effect.Effect<number, Error> =>
  discountRate === 0
    ? Effect.fail(new Error("折扣率不能为零"))
    : Effect.succeed(total - (total * discountRate) / 100)

// 模拟异步任务,从数据库获取交易金额
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

// 使用 `flatMap` 链接获取和折扣应用
const finalAmount = pipe(
  fetchTransactionAmount,
  Effect.flatMap((amount) => applyDiscount(amount, 5))
)

Effect.runPromise(finalAmount).then(console.log)
// 输出: 95

确保考虑所有效果

确保 Effect.flatMap 中的所有效果都对最终计算有贡献。如果你忽略了一个效果,可能会导致意外行为:

Effect.flatMap((amount) => {
  // 这个效果将被忽略
  Effect.sync(() => console.log(`Apply a discount to: ${amount}`))
  return applyDiscount(amount, 5)
})

在这种情况下,Effect.sync 调用被忽略,并且不影响 applyDiscount(amount, 5) 的结果。为了正确处理效果,请确保使用 Effect.mapEffect.flatMapEffect.andThen 或 Effect.tap 等函数显式链接它们。

andThen(链式操作)

用于串联两个动作,其中第二个动作可以依赖于第一个动作的结果。

​语法​

const transformedEffect = pipe(myEffect, Effect.andThen(anotherEffect))
// 或
const transformedEffect = Effect.andThen(myEffect, anotherEffect)
// 或
const transformedEffect = myEffect.pipe(Effect.andThen(anotherEffect))

当需要按顺序执行多个动作,且后续动作依赖于前一个动作的结果时,使用andThen。这在组合多个效果或处理必须按顺序进行的计算时非常有用。

第二个动作可以是以下形式之一:

  1. 一个值(类似于Effect.as
  2. 返回值的函数(类似于Effect.map
  3. 一个Promise
  4. 返回Promise的函数
  5. 一个Effect
  6. 返回Effect的函数(类似于Effect.flatMap

​示例​​(基于获取金额应用折扣)

下面通过比较Effect.andThenEffect.mapEffect.flatMap的示例来说明:

import { pipe, Effect } from "effect"

// 安全地对交易金额应用折扣的函数
const applyDiscount = (
  total: number,
  discountRate: number
): Effect.Effect<number, Error> =>
  discountRate === 0
    ? Effect.fail(new Error("折扣率不能为零"))
    : Effect.succeed(total - (total * discountRate) / 100)

// 模拟从数据库异步获取交易金额的任务
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

// 使用Effect.map和Effect.flatMap
const result1 = pipe(
  fetchTransactionAmount,
  Effect.map((amount) => amount * 2),
  Effect.flatMap((amount) => applyDiscount(amount, 5))
)

Effect.runPromise(result1).then(console.log) // 输出: 190

// 使用Effect.andThen
const result2 = pipe(
  fetchTransactionAmount,
  Effect.andThen((amount) => amount * 2),
  Effect.andThen((amount) => applyDiscount(amount, 5))
)

Effect.runPromise(result2).then(console.log) // 输出: 190

Option和Either与andThen的结合

Option和Either通常用于处理可选值或缺失值以及简单的错误情况。这些类型与Effect.andThen能很好地集成。当与Effect.andThen一起使用时,这些操作会被归类为前述的第5和第6种场景(如前文所述),因为在此上下文中OptionEither都被视为效果(effects)。

​示例​​(使用Option)

import { pipe, Effect, Option } from "effect"

// 模拟从数据库异步获取数字的任务
const fetchNumberValue = Effect.tryPromise(() => Promise.resolve(42))

//      ┌─── Effect<number, UnknownException | NoSuchElementException, never>
//      ▼
const program = pipe(
  fetchNumberValue,
  Effect.andThen((x) => (x > 0 ? Option.some(x) : Option.none()))
)

你可能会期望program的类型是Effect<Option<number>, UnknownException, never>,但实际上它是Effect<number, UnknownException | NoSuchElementException, never>

这是因为Option<A>被视为类型为Effect<A, NoSuchElementException>的效果,因此可能的错误会被合并为一个联合类型。

Option 作为 Effect

类型为 Option<A> 的值会被解释为类型为 Effect<A, NoSuchElementException> 的效果。

​示例​​(使用 Either)

import { pipe, Effect, Either } from "effect"

// 从字符串解析整数的函数(可能失败)
const parseInteger = (input: string): Either.Either<number, string> =>
  isNaN(parseInt(input))
    ? Either.left("无效的整数")
    : Either.right(parseInt(input))

// 模拟从数据库异步获取字符串的任务
const fetchStringValue = Effect.tryPromise(() => Promise.resolve("42"))

//      ┌─── Effect<number, string | UnknownException, never>
//      ▼
const program = pipe(
  fetchStringValue,
  Effect.andThen((str) => parseInteger(str))
)

虽然人们可能期望 program 的类型是 Effect<Either<number, string>, UnknownException, never>,但实际上它是 Effect<number, string | UnknownException, never>

这是因为 Either<A, E> 被视为类型为 Effect<A, E> 的效应,意味着错误会被合并成一个联合类型。

Either 作为 Effect

类型为 Either<A, E> 的值会被解释为类型为 Effect<A, E> 的效应。

tap

在不改变原始值的情况下,对某个操作的结果执行副作用。

当你想要执行诸如日志记录或追踪之类的副作用,但又不希望修改主值时,可以使用 Effect.tap。这在需要观察或记录某个操作,同时希望将原始值传递给下一步时非常有用。

Effect.tap 的工作方式类似于 Effect.flatMap,但它会忽略传递给它的函数的结果。前一个操作的值仍然可以在链式调用的下一部分中使用。需要注意的是,如果副作用失败,整个链式调用也会失败。

​示例​​(在流程中记录步骤)

import { pipe, Effect, Console } from "effect"

// 安全地对交易金额应用折扣的函数
const applyDiscount = (
  total: number,
  discountRate: number
): Effect.Effect<number, Error> =>
  discountRate === 0
    ? Effect.fail(new Error("折扣率不能为零"))
    : Effect.succeed(total - (total * discountRate) / 100)

// 模拟从数据库异步获取交易金额的任务
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

const finalAmount = pipe(
  fetchTransactionAmount,
  // 记录获取到的交易金额
  Effect.tap((amount) => Console.log(`应用折扣至: ${amount}`)),
  // `amount` 仍然可用!
  Effect.flatMap((amount) => applyDiscount(amount, 5))
)

Effect.runPromise(finalAmount).then(console.log)
/*
输出:
应用折扣至: 100
95
*/

在这个示例中,Effect.tap 用于在应用折扣之前记录交易金额,而不修改值本身。原始值(amount)仍然可用于下一个操作(applyDiscount)。

使用 Effect.tap 允许我们在计算过程中执行副作用而不改变结果。这对于日志记录、执行额外操作或观察中间值而不干扰主计算流程非常有用。

all

将多个效果合并为一个,并根据输入结构返回结果。

当你需要运行多个效果并将它们的结果合并为单一输出时,可以使用 Effect.all。它支持元组、可迭代对象、结构体和记录,适用于不同的输入类型。

例如,如果输入是一个元组:

//         ┌─── 一个由效果组成的元组
//         ▼
Effect.all([effect1, effect2, ...])

效果会按顺序执行,结果是一个包含结果的新效果,结果以元组形式呈现。元组中的结果顺序与传递给 Effect.all 的效果顺序一致。

默认情况下,Effect.all 会按顺序运行效果,并生成一个包含结果的元组或对象。如果任何一个效果失败,它会停止执行(短路)并传播错误。

有关如何使用 Effect.all 的更多信息,请参阅 收集。

​示例​​(结合配置和数据库检查)

import { Effect } from "effect"

// 模拟从文件读取配置的函数
const webConfig = Effect.promise(() =>
  Promise.resolve({ dbConnection: "localhost", port: 8080 })
)

// 模拟测试数据库连接性的函数
const checkDatabaseConnectivity = Effect.promise(() =>
  Promise.resolve("Connected to Database")
)

// 合并两个效果以执行启动检查
const startupChecks = Effect.all([webConfig, checkDatabaseConnectivity])

Effect.runPromise(startupChecks).then(([config, dbStatus]) => {
  console.log(
    `配置: ${JSON.stringify(config)}\n数据库状态: ${dbStatus}`
  )
})
/*
输出:
配置: {"dbConnection":"localhost","port":8080}
数据库状态: Connected to Database
*/

构建你的第一个处理流程

现在让我们结合 pipe 函数、Effect.all 和 Effect.andThen 来创建一个执行一系列转换的处理流程。

​示例​​(构建交易处理流程)

import { Effect, pipe } from "effect"

// 为交易金额添加小额服务费的函数
const addServiceCharge = (amount: number) => amount + 1

// 安全地为交易金额应用折扣的函数
const applyDiscount = (
  total: number,
  discountRate: number
): Effect.Effect<number, Error> =>
  discountRate === 0
    ? Effect.fail(new Error("折扣率不能为零"))
    : Effect.succeed(total - (total * discountRate) / 100)

// 模拟从数据库异步获取交易金额的任务
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

// 模拟从配置文件异步获取折扣率的任务
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))

// 使用效果管道组装程序
const program = pipe(
  // 合并两个获取效果以得到交易金额和折扣率
  Effect.all([fetchTransactionAmount, fetchDiscountRate]),

  // 将折扣应用于交易金额
  Effect.andThen(([transactionAmount, discountRate]) =>
    applyDiscount(transactionAmount, discountRate)
  ),

  // 为折扣后的金额添加服务费
  Effect.andThen(addServiceCharge),

  // 格式化最终结果用于显示
  Effect.andThen(
    (finalAmount) => `最终收费金额: ${finalAmount}`
  )
)

// 执行程序并打印结果
Effect.runPromise(program).then(console.log)
// 输出: "最终收费金额: 96"

这个处理流程展示了如何通过将不同的效果组合成清晰、可读的流程来组织你的代码。

管道方法

Effect 提供了一个 pipe 方法,其工作方式类似于 rxjs 中的 pipe 方法。该方法允许你将多个操作串联起来,使代码更加简洁和易读。

​语法​

const result = effect.pipe(func1, func2, ..., funcN)

这等同于使用 ​​函数​pipe 如下:

const result = pipe(effect, func1, func2, ..., funcN)

pipe 方法可用于所有 effects 和许多其他数据类型,无需导入 pipe 函数,从而节省一些按键操作。

​示例​​(使用 pipe 方法)

让我们重写一个 之前的示例,这次使用 pipe 方法。

import { Effect } from "effect"

const addServiceCharge = (amount: number) => amount + 1

const applyDiscount = (
  total: number,
  discountRate: number
): Effect.Effect<number, Error> =>
  discountRate === 0
    ? Effect.fail(new Error("Discount rate cannot be zero"))
    : Effect.succeed(total - (total * discountRate) / 100)

const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))

const program = Effect.all([
  fetchTransactionAmount,
  fetchDiscountRate
]).pipe(
  Effect.andThen(([transactionAmount, discountRate]) =>
    applyDiscount(transactionAmount, discountRate)
  ),
  Effect.andThen(addServiceCharge),
  Effect.andThen(
    (finalAmount) => `Final amount to charge: ${finalAmount}`
  )
)

速查表

让我们总结一下目前见过的转换函数:

API输入输出
mapEffect<A, E, R>, A => BEffect<B, E, R>
flatMapEffect<A, E, R>, A => Effect<B, E, R>Effect<B, E, R>
andThenEffect<A, E, R>, *Effect<B, E, R>
tapEffect<A, E, R>, A => Effect<B, E, R>Effect<A, E, R>
all[Effect<A, E, R>, Effect<B, E, R>, ...]Effect<[A, B, ...], E, R>

控制流操作符

尽管 JavaScript 提供了内置的控制流结构,但 Effect 还提供了额外的控制流函数,这些函数在 Effect 应用程序中非常有用。在本节中,我们将介绍控制执行流程的不同方式。

if 表达式

在处理 Effect 值时,我们可以使用标准的 JavaScript if-then-else 语句:

​示例​​(无效体重时返回 None)

这里我们使用 Option 数据类型来表示没有有效值的情况。

import { Effect, Option } from "effect"

// 验证体重并返回 Option 的函数
const validateWeightOption = (
  weight: number
): Effect.Effect<Option.Option<number>> => {
  if (weight >= 0) {
    // 如果体重有效,返回 Some
    return Effect.succeed(Option.some(weight))
  } else {
    // 如果体重无效,返回 None
    return Effect.succeed(Option.none())
  }
}

​示例​​(无效体重时返回错误)

你也可以通过使用错误通道来处理无效输入,这允许你在输入无效时返回一个错误:

import { Effect } from "effect"

// 验证体重或返回错误的函数
const validateWeightOrFail = (
  weight: number
): Effect.Effect<number, string> => {
  if (weight >= 0) {
    // 如果体重有效,返回体重值
    return Effect.succeed(weight)
  } else {
    // 如果无效,返回错误信息
    return Effect.fail(`negative input: ${weight}`)
  }
}

条件运算符

if

根据一个有副作用的谓词(predicate)求值结果,执行两个效果中的一个。

使用 Effect.if 可以根据谓词效果求值为 truefalse 来运行两个效果中的一个。如果谓词为 true,则执行 onTrue 效果;如果为 false,则执行 onFalse 效果。

​示例​​(模拟抛硬币)

在这个例子中,我们使用 Random.nextBoolean 生成一个随机布尔值来模拟虚拟抛硬币。如果值为 trueonTrue 效果会记录 "Head";如果值为 falseonFalse 效果会记录 "Tail"。

import { Effect, Random, Console } from "effect"

const flipTheCoin = Effect.if(Random.nextBoolean, {
  onTrue: () => Console.log("Head"), // 如果谓词为 true 则运行
  onFalse: () => Console.log("Tail") // 如果谓词为 false 则运行
})

Effect.runFork(flipTheCoin)

when

根据布尔条件有条件地执行一个效果。

Effect.when 允许你有条件地执行一个效果,类似于使用 if (condition) 表达式,但增加了处理效果的能力。如果条件为 true,则执行效果;否则,不执行任何操作。

效果的结果会被包装在 Option<A> 中,以指示效果是否被执行。如果条件为 true,效果的结果会被包装在 Some 中;如果条件为 false,结果为 None,表示效果被跳过。

​示例​​(条件效果执行)

import { Effect, Option } from "effect"

const validateWeightOption = (
  weight: number
): Effect.Effect<Option.Option<number>> =>
  // 如果重量为非负数,则有条件地执行效果
  Effect.succeed(weight).pipe(Effect.when(() => weight >= 0))

// 使用有效重量运行
Effect.runPromise(validateWeightOption(100)).then(console.log)
/*
输出:
{
  _id: "Option",
  _tag: "Some",
  value: 100
}
*/

// 使用无效重量运行
Effect.runPromise(validateWeightOption(-5)).then(console.log)
/*
输出:
{
  _id: "Option",
  _tag: "None"
}
*/

在这个例子中,Option 数据类型用于表示有效值的存在或缺失。如果条件求值为 true(在本例中,如果重量为非负数),则效果被执行并包装在 Some 中;否则,结果为 None

whenEffect

根据另一个效应的结果有条件地执行一个效应。

当决定是否执行效应的条件依赖于另一个产生布尔值的效应的结果时,使用 Effect.whenEffect
如果条件效应评估为 true,则执行指定的效应。
如果评估为 false,则不执行任何效应。

效应的结果会被包装在 Option<A> 中,以指示效应是否被执行。
如果条件为 true,效应的结果会被包装在 Some 中。
如果条件为 false,结果为 None,表示效应被跳过。

​示例​​(使用效应作为条件)

以下函数创建一个随机整数,但仅当随机生成的布尔值为 true 时才会执行。

import { Effect, Random } from "effect"

const randomIntOption = Random.nextInt.pipe(
  Effect.whenEffect(Random.nextBoolean)
)

console.log(Effect.runSync(randomIntOption))
/*
示例输出:
{ _id: 'Option', _tag: 'Some', value: 8609104974198840 }
*/

unless / unlessEffect

Effect.unlessEffect.unlessEffect 函数与 when* 函数类似,但它们等价于 if (!condition) expression 的构造。

合并执行(Zipping)

zip 操作

将两个效果合并为单一效果,生成包含两者结果的元组。

Effect.zip 函数会先执行第一个效果(左侧),然后执行第二个效果(右侧)。当两个效果都成功完成后,它们的结果会被组合成一个元组。

​示例​​(顺序合并两个效果)

import { Effect } from "effect"

const task1 = Effect.succeed(1).pipe(
  Effect.delay("200毫秒"),
  Effect.tap(Effect.log("任务1完成"))
)

const task2 = Effect.succeed("你好").pipe(
  Effect.delay("100毫秒"),
  Effect.tap(Effect.log("任务2完成"))
)

// 合并两个效果
//
//      ┌─── Effect<[number, string], never, never>
//      ▼
const program = Effect.zip(task1, task2)

Effect.runPromise(program).then(console.log)
/*
输出:
timestamp=... level=INFO fiber=#0 message="任务1完成"
timestamp=... level=INFO fiber=#0 message="任务2完成"
[ 1, '你好' ]
*/

默认情况下,效果会按顺序执行。如需并发执行,可使用 { concurrent: true } 选项。

​示例​​(并发合并两个效果)

import { Effect } from "effect"

const task1 = Effect.succeed(1).pipe(
  Effect.delay("200毫秒"),
  Effect.tap(Effect.log("任务1完成"))
)

const task2 = Effect.succeed("你好").pipe(
  Effect.delay("100毫秒"),
  Effect.tap(Effect.log("任务2完成"))
)

// 使用并发选项同时执行两个效果
const program = Effect.zip(task1, task2, { concurrent: true })

Effect.runPromise(program).then(console.log)
/*
输出:
timestamp=... level=INFO fiber=#3 message="任务2完成"
timestamp=... level=INFO fiber=#2 message="任务1完成"
[ 1, '你好' ]
*/

在这个并发版本中,两个效果会并行执行。虽然 task2 会先完成,但两个任务的结果都会在完成后立即被记录和处理。

zipWith 操作

顺序合并两个效果,并通过函数处理它们的结果以生成单个值。

Effect.zipWith 函数与 Effect.zip 类似,但不会返回结果元组,而是通过提供的函数处理两个效果的结果,将它们合并为单个值。

默认情况下,效果会按顺序执行。如需并发执行,可使用 { concurrent: true } 选项。

​示例​​(使用自定义函数合并效果)

import { Effect } from "effect"

const task1 = Effect.succeed(1).pipe(
  Effect.delay("200毫秒"),
  Effect.tap(Effect.log("任务1完成"))
)
const task2 = Effect.succeed("你好").pipe(
  Effect.delay("100毫秒"),
  Effect.tap(Effect.log("任务2完成"))
)

//      ┌─── Effect<number, never, never>
//      ▼
const task3 = Effect.zipWith(
  task1,
  task2,
  // 将结果合并为单个值
  (数字, 字符串) => 数字 + 字符串.length
)

Effect.runPromise(task3).then(console.log)
/*
输出:
timestamp=... level=INFO fiber=#3 message="任务1完成"
timestamp=... level=INFO fiber=#2 message="任务2完成"
6
*/

循环操作

loop循环

Effect.loop 函数允许您使用 step 函数重复更新状态,直到 while 函数定义的条件变为 false。它会将中间状态收集到数组中,并作为最终结果返回。

​语法​

Effect.loop(initial, {
  while: (state) => boolean,
  step: (state) => state,
  body: (state) => Effect
})

该函数类似于 JavaScript 中的 while 循环,但增加了带副作用计算的功能:

let state = initial
const result = []

while (options.while(state)) {
  result.push(options.body(state)) // 执行带副作用的操作
  state = options.step(state) // 更新状态
}

return result

​示例​​(收集结果的循环)

import { Effect } from "effect"

// 一个运行5次的循环,收集每次迭代的结果
const result = Effect.loop(
  // 初始状态
  1,
  {
    // 继续循环的条件
    while: (state) => state <= 5,
    // 状态更新函数
    step: (state) => state + 1,
    // 每次迭代要执行的效果
    body: (state) => Effect.succeed(state)
  }
)

Effect.runPromise(result).then(console.log)
// 输出: [1, 2, 3, 4, 5]

在这个示例中,循环从状态 1 开始,直到状态超过 5 时结束。每次状态增加 1,并被收集到数组中,成为最终结果。

丢弃中间结果

discard 选项设置为 true 时,将丢弃每次带副作用操作的结果,返回 void 而不是数组。

​示例​​(丢弃结果的循环)

import { Effect, Console } from "effect"

const result = Effect.loop(
  // 初始状态
  1,
  {
    // 继续循环的条件
    while: (state) => state <= 5,
    // 状态更新函数
    step: (state) => state + 1,
    // 每次迭代要执行的效果
    body: (state) => Console.log(`当前状态 ${state}`),
    // 丢弃中间结果
    discard: true
  }
)

Effect.runPromise(result).then(console.log)
/*
输出:
当前状态 1
当前状态 2
当前状态 3
当前状态 4
当前状态 5
undefined
*/

在这个示例中,循环在每次迭代时执行记录当前索引的副作用操作,但丢弃了所有中间结果。最终结果为 undefined

迭代(iterate)

Effect.iterate 函数允许你通过一个带有副作用(effectful)的操作反复更新状态。它在每次迭代中运行 body 副作用来更新状态,并在 while 条件评估为 true 时继续执行。

​语法​

Effect.iterate(初始值, {
  while: (当前结果) => 布尔值,
  body: (当前结果) => Effect
})

该函数类似于 JavaScript 中的 while 循环,但增加了副作用计算的支持:

let 当前结果 = 初始值

while (条件.while(当前结果)) {
  当前结果 = 操作.body(当前结果)
}

return 当前结果

​示例​​(带副作用的迭代)

import { Effect } from "effect"

const 结果 = Effect.iterate(
  // 初始值
  1,
  {
    // 继续迭代的条件
    while: (当前结果) => 当前结果 <= 5,
    // 更新结果的操作
    body: (当前结果) => Effect.succeed(当前结果 + 1)
  }
)

Effect.runPromise(结果).then(console.log)
// 输出: 6

遍历(forEach)

Iterable 中的每个元素执行一个带副作用的操作。

Effect.forEach 函数对可迭代对象中的每个元素应用指定的操作,生成一个新的副作用,最终返回一个结果数组。如果任何副作用失败,迭代会立即停止(短路),并传播错误。

通过 concurrency 选项可以控制并发执行的操作数量。默认情况下,操作是按顺序执行的。

​示例​​(对可迭代元素应用副作用)

import { Effect, Console } from "effect"

const 结果 = Effect.forEach([1, 2, 3, 4, 5], (元素, 索引) =>
  Console.log(`当前索引 ${索引}`).pipe(Effect.as(元素 * 2))
)

Effect.runPromise(结果).then(console.log)
/*
输出:
当前索引 0
当前索引 1
当前索引 2
当前索引 3
当前索引 4
[ 2, 4, 6, 8, 10 ]
*/

在这个例子中,我们遍历数组 [1, 2, 3, 4, 5],并对每个元素应用一个记录当前索引的副作用。Effect.as(元素 * 2) 操作对每个值进行转换,最终得到数组 [2, 4, 6, 8, 10]。最后的输出是所有转换后的值的集合。

忽略结果(Discarding Results)

discard 选项设为 true 时,将丢弃每个副作用操作的执行结果,最终返回 void 而非结果数组。

​示例​​(使用 discard 忽略结果)

import { Effect, Console } from "effect"

// 执行副作用但丢弃结果
const 结果 = Effect.forEach(
  [1, 2, 3, 4, 5],
  (元素, 索引) =>
    Console.log(`当前索引 ${索引}`).pipe(Effect.as(元素 * 2)),
  { discard: true }
)

Effect.runPromise(结果).then(console.log)
/*
输出:
当前索引 0
当前索引 1
当前索引 2
当前索引 3
当前索引 4
undefined
*/

这种情况下,副作用仍会对每个元素执行,但结果会被丢弃,因此最终输出为 undefined

收集

all

将多个效果合并为一个,并根据输入结构返回结果。

当需要运行多个效果并将它们的结果合并为单一输出时,使用 Effect.all。它支持元组、可迭代对象、结构体和记录,适用于不同类型的输入。

如果任一效果失败,它将停止执行(短路)并传播错误。要改变此行为,可以使用 mode 选项,允许所有效果运行并将结果收集为 Either 或 Option。

可以通过 并发选项 控制执行顺序(例如,顺序执行 vs. 并发执行)。

例如,如果输入是元组:

//         ┌─── 一个由效果组成的元组
//         ▼
Effect.all([effect1, effect2, ...])

效果会按顺序执行,结果是一个包含结果的新效果,结果以元组形式呈现。元组中的结果顺序与传递给 Effect.all 的效果顺序一致。

让我们看看不同类型的结构示例:元组、可迭代对象、对象和记录。

​示例​​(合并元组中的效果)

import { Effect, Console } from "effect"

const tupleOfEffects = [
  Effect.succeed(42).pipe(Effect.tap(Console.log)),
  Effect.succeed("Hello").pipe(Effect.tap(Console.log))
] as const

//      ┌─── Effect<[number, string], never, never>
//      ▼
const resultsAsTuple = Effect.all(tupleOfEffects)

Effect.runPromise(resultsAsTuple).then(console.log)
/*
输出:
42
Hello
[ 42, 'Hello' ]
*/

​示例​​(合并可迭代对象中的效果)

import { Effect, Console } from "effect"

const iterableOfEffects: Iterable<Effect.Effect<number>> = [1, 2, 3].map(
  (n) => Effect.succeed(n).pipe(Effect.tap(Console.log))
)

//      ┌─── Effect<number[], never, never>
//      ▼
const resultsAsArray = Effect.all(iterableOfEffects)

Effect.runPromise(resultsAsArray).then(console.log)
/*
输出:
1
2
3
[ 1, 2, 3 ]
*/

​示例​​(合并结构体中的效果)

import { Effect, Console } from "effect"

const structOfEffects = {
  a: Effect.succeed(42).pipe(Effect.tap(Console.log)),
  b: Effect.succeed("Hello").pipe(Effect.tap(Console.log))
}

//      ┌─── Effect<{ a: number; b: string; }, never, never>
//      ▼
const resultsAsStruct = Effect.all(structOfEffects)

Effect.runPromise(resultsAsStruct).then(console.log)
/*
输出:
42
Hello
{ a: 42, b: 'Hello' }
*/

​示例​​(合并记录中的效果)

import { Effect, Console } from "effect"

const recordOfEffects: Record<string, Effect.Effect<number>> = {
  key1: Effect.succeed(1).pipe(Effect.tap(Console.log)),
  key2: Effect.succeed(2).pipe(Effect.tap(Console.log))
}

//      ┌─── Effect<{ [x: string]: number; }, never, never>
//      ▼
const resultsAsRecord = Effect.all(recordOfEffects)

Effect.runPromise(resultsAsRecord).then(console.log)
/*
输出:
1
2
{ key1: 1, key2: 2 }
*/
短路行为

Effect.all 函数会在遇到第一个错误时停止执行,这种行为称为"短路"。如果集合中的任何一个效果失败,剩余的效果将不会运行,并且错误会被传播。

​示例​​ (首次失败即终止)

import { Effect, Console } from "effect"

const program = Effect.all([
  Effect.succeed("任务1").pipe(Effect.tap(Console.log)),
  Effect.fail("任务2: 出错了!").pipe(Effect.tap(Console.log)),
  // 由于前面的失败,此任务不会执行
  Effect.succeed("任务3").pipe(Effect.tap(Console.log))
])

Effect.runPromiseExit(program).then(console.log)
/*
输出:
任务1
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: { _id: 'Cause', _tag: 'Fail', failure: '任务2: 出错了!' }
}
*/

您可以通过使用 mode 选项来覆盖此行为。

mode 选项

{ mode: "either" } 选项会改变 Effect.all 的行为,确保所有效果都会运行,即使其中一些失败。此模式不会在第一次失败时停止,而是收集成功和失败的结果,返回一个由 Either 实例组成的数组,其中每个结果要么是 Right(成功),要么是 Left(失败)。

​示例​​ (使用 mode: "either" 收集结果)

import { Effect, Console } from "effect"

const effects = [
  Effect.succeed("任务1").pipe(Effect.tap(Console.log)),
  Effect.fail("任务2: 出错了!").pipe(Effect.tap(Console.log)),
  Effect.succeed("任务3").pipe(Effect.tap(Console.log))
]

const program = Effect.all(effects, { mode: "either" })

Effect.runPromiseExit(program).then(console.log)
/*
输出:
任务1
任务3
{
  _id: 'Exit',
  _tag: 'Success',
  value: [
    { _id: 'Either', _tag: 'Right', right: '任务1' },
    { _id: 'Either', _tag: 'Left', left: '任务2: 出错了!' },
    { _id: 'Either', _tag: 'Right', right: '任务3' }
  ]
}
*/

类似地,{ mode: "validate" } 选项使用 Option 来表示成功或失败。每个效果成功时返回 None,失败时返回包含错误的 Some

​示例​​ (使用 mode: "validate" 收集结果)

import { Effect, Console } from "effect"

const effects = [
  Effect.succeed("任务1").pipe(Effect.tap(Console.log)),
  Effect.fail("任务2: 出错了!").pipe(Effect.tap(Console.log)),
  Effect.succeed("任务3").pipe(Effect.tap(Console.log))
]

const program = Effect.all(effects, { mode: "validate" })

Effect.runPromiseExit(program).then((result) => console.log("%o", result))
/*
输出:
任务1
任务3
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: {
    _id: 'Cause',
    _tag: 'Fail',
    failure: [
      { _id: 'Option', _tag: 'None' },
      { _id: 'Option', _tag: 'Some', value: '任务2: 出错了!' },
      { _id: 'Option', _tag: 'None' }
    ]
  }
}
*/