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

使用 Generator

学习如何使用 Generator 编写带副作用的代码,改善控制流、处理错误并简化异步操作。

Effect 提供了一种便捷的语法,它类似于 async/await,让你可以使用 generators 编写带副作用的代码。

Optional Feature

在 Effect 中,使用 generator 是一个可选特性。如果你觉得 generator 不太熟悉,或者更喜欢其他的编码风格,可以阅读 Effect 中关于构建 管道的文档。

理解 Effect.gen

Effect.gen 工具借助 JavaScript 的 generator 函数,简化了编写带副作用代码的工作。这种方式能让你的代码在外观和行为上更接近传统的同步代码,从而提升可读性并改善错误管理。

示例(执行带折扣的交易)

让我们来看一个实用的程序,它执行一系列在应用逻辑中常见的转换操作:

import { Effect } from "effect"

// Function to add a small service charge to a transaction amount
const addServiceCharge = (amount: number) => amount + 1

// Function to apply a discount safely to a transaction amount
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)

// Simulated asynchronous task to fetch a transaction amount from a
// database
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))

// Simulated asynchronous task to fetch a discount rate from a
// configuration file
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))

// Assembling the program using a generator function
const program = Effect.gen(function* () {
  // Retrieve the transaction amount
  const transactionAmount = yield* fetchTransactionAmount

  // Retrieve the discount rate
  const discountRate = yield* fetchDiscountRate

  // Calculate discounted amount
  const discountedAmount = yield* applyDiscount(transactionAmount, discountRate)

  // Apply service charge
  const finalAmount = addServiceCharge(discountedAmount)

  // Return the total amount after applying the charge
  return `Final amount to charge: ${finalAmount}`
})

// Execute the program and log the result
const result = await Effect.runPromise(program) // => "Final amount to charge: 96"
console.log(result)

使用 Effect.gen 时需要遵循的关键步骤:

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

如果你在 generator 中通过 yield* 处理的任何一个 effect 失败了,那么 generator 会停止执行,并以该失败退出。

Required TypeScript Configuration

只有在 tsconfig.json 文件中使用 downlevelIteration 标志,或者把 target 设为 "es2015" 或更高版本时,才能使用 generator API。

比较 Effect.gen 与 async/await

如果你熟悉 async/await,可能会注意到两者的代码编写流程很相似。

让我们比较一下这两种方式:

Using 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("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))

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 `Final amount to charge: ${finalAmount}`
})

await Effect.runPromise(program) // => "Final amount to charge: 96"
Using Async / Await
const addServiceCharge = (amount: number) => amount + 1

const applyDiscount = (total: number, discountRate: number): Promise<number> =>
  discountRate === 0
    ? Promise.reject(new Error("Discount rate cannot be zero"))
    : 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 `Final amount to charge: ${finalAmount}`
}

await program() // => "Final amount to charge: 96"

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

拥抱控制流

在配合 generator 使用 Effect.gen 时,一个显著优势是它能够在 generator 函数内部使用标准的控制流结构。这些结构包括 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("Invalid tax rate"))

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

  while (true) {
    if (i === 10) {
      break // Break the loop when counter reaches 10
    } else {
      if (i % 2 === 0) {
        // Calculate tax for even numbers
        console.log(yield* calculateTax(100, i))
      }
      i++
      continue
    }
  }
})

await Effect.runPromise(program) // => undefined
/*
Output:
2
4
6
8
*/

如何抛出错误

Effect.gen API 让你可以通过 yield 一个失败的 effect,把错误处理直接整合进工作流中。 你可以像下面这个示例一样,用 Effect.fail 引入错误。

示例(向流程中引入错误)

import { Effect, Console } from "effect"

const task1 = Console.log("task1...")
const task2 = Console.log("task2...")

const program = Effect.gen(function* () {
  // Perform some tasks
  yield* task1
  yield* task2
  // Introduce an error
  return yield* Effect.fail("Something went wrong!")
})

try {
  await Effect.runPromise(program)
} catch (e) {
  console.error(e)
  /*
  Output:
  task1...
  task2...
  */
  e // => "Something went wrong!"
}

短路的作用

在使用 Effect.gen 时,理解它如何处理错误很重要。 这个 API 会在遇到第一个错误时停止执行,并返回该错误。

这对你的代码有什么影响?如果你有一系列顺序执行的操作,那么其中任何一个失败后,其余操作都不会运行,并且该错误会被返回。

简单来说,如果某个环节出了问题,程序会立刻停在那里,并把错误交给你。

如果你不想在出错时停止,可以使用 Effect.result 方法把错误封装进 Result 数据类型:请参阅管理预期错误的示例

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

import { Effect, Console } from "effect"

const task1 = Console.log("task1...")
const task2 = Console.log("task2...")
const failure = Effect.fail("Something went wrong!")
const task4 = Console.log("task4...")

const program = Effect.gen(function* () {
  yield* task1
  yield* task2
  // The program stops here due to the error
  yield* failure
  // The following lines never run
  yield* task4
  return "some result"
})

Effect.runPromise(program).then(console.log, console.error)
/*
Output:
task1...
task2...
Something went wrong!
*/

尽管执行永远不会到达失败之后的代码,但除非你在失败后显式 return,否则 TypeScript 仍可能认为错误下方的代码是可到达的。

例如,考虑下面这个场景,你希望收窄某个变量的类型:

示例(没有显式 return 时的类型收窄)

import { Effect } from "effect"

type User = {
  readonly name: string
}

// Imagine this function checks a database or an external service
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) {
      // Even though we fail here, TypeScript still thinks
      // 'user' might be undefined later
      yield* Effect.fail(`User with id ${id} not found`)
    }

    // @errors: 18048
    return `Hello, ${user.name}!`
  })
}

在这个示例中,TypeScript 仍然认为 user 可能是 undefined,因为失败之后没有显式 return。

要解决这个问题,请在调用 Effect.fail 之后立即显式 return:

示例(有显式 return 时的类型收窄)

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) {
      // Explicitly return after failing
      return yield* Effect.fail(`User with id ${id} not found`)
    }

    // Now TypeScript knows that 'user' is not undefined
    return `Hello, ${user.name}!`
  })
}

greetUser.length // => 1
Further Learning

如果想进一步了解 Effect 中的错误处理,请参阅错误 管理章节。

传递 this

在某些情况下,你可能需要把当前对象(this)的引用传入 generator 函数体。 你可以借助一个把该引用作为第一个参数接收的重载来实现:

示例(向 Generator 传递 this

import { Effect } from "effect"

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

    yield* Effect.log(`Computed value: ${n}`)

    return n
  })
}

const result = await Effect.runPromise(new MyClass().compute) // => 2
console.log(result)
/*
Output:
timestamp=... level=INFO fiber=#0 message="Computed value: 2"
*/