使用 Eithers 处理调用链传递的错误的方法

Approach to handle errors passed up the call chain using Eithers

提问人:leepowell 提问时间:11/6/2023 更新时间:11/16/2023 访问量:95

问:

我目前正在学习更多关于函数式编程和错误处理的替代方法,而不是我习惯的(主要是尝试/捕获)。并且一直在研究各种编程语言中的 Either monad。我一直在尝试将这个概念应用于一个使用 Express 和 fp-ts 的小型简单应用程序。

假设我有以下用于处理请求的体系结构(假设它是从数据库中检索实体):

Express -> route handler -> controller -> repository -> data source -> database

这为错误提供了多个可能发生的地方。首先,让我们看一下数据源。我正在尝试将其限制为一个接口,以便将来在需要时交换数据源。所以我的数据源界面最初看起来像:

export type TodoDataSource = {
  findAll(): Promise<ReadonlyArray<TodoDataSourceDTO>>;
  findOne(id: string): Promise<TodoDataSourceDTO | null>;
  create(data: CreateTodoDTO): Promise<TodoDataSourceDTO>;
  update(id: string, data: UpdateTodoDTO): Promise<TodoDataSourceDTO>;
  remove(id: string): Promise<TodoDataSourceDTO>;
};

然后,此数据源在创建时传递到存储库工厂函数:

export function createTodoRepository(
  dataSource: TodoDataSource,
): TodoRepository {
  return {
    findAll: async () => await findAll(dataSource),
    findOne: async (id: string) => await findOne(dataSource, id),
    create: async (data: CreateTodoDTO) => await create(dataSource, data),
    update: async (id: string, data: UpdateTodoDTO) =>
      await update(dataSource, id, data),
    remove: async (id: string) => await remove(dataSource, id),
  };
}

以类似的方式,我的存储库实现旨在满足接口的契约:TodoRepository

export type TodoRepository = {
  findAll(): Promise<ReadonlyArray<Todo>>;
  findOne(id: string): Promise<Todo | null>;
  create(data: CreateTodoDTO): Promise<Todo>;
  update(id: string, data: UpdateTodoDTO): Promise<Todo>;
  remove(id: string): Promise<Todo>;
};

我遇到的问题是,一旦我尝试应用使用s的方法,我的接口就会开始变得非常紧密地耦合并且非常冗长。Either

首先,让我将数据源更新为用作其返回类型:Either

import * as TaskEither from 'fp-ts/TaskEither';

export type DataSourceError = { type: "DATA_SOURCE_ERROR"; error?: unknown };

export type TodoDataSource = {
  findAll(): Promise<
    TaskEither.TaskEither<DataSourceError, ReadonlyArray<TodoDataSourceDTO>>
  >;
  findOne(
    id: string,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO | null>>;
  create(
    data: CreateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
  update(
    id: string,
    data: UpdateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
  remove(
    id: string,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
};

还不错。这将处理基础数据源抛出的任何意外情况 - 我们可以捕获并转换为 .伟大。Either

现在到我的存储库界面:

import type * as TaskEither from "fp-ts/TaskEither";
import { type DataSourceError } from "../../infra/data-sources/todo.data-source";
import { type ParseError } from "../../shared/parsers";

export type TodoNotFoundError = { type: "TODO_NOT_FOUND"; id: string };

export type TodoRepository = {
  findAll(): Promise<
    TaskEither.TaskEither<DataSourceError | ParseError, ReadonlyArray<Todo>>
  >;
  findOne(
    id: string,
  ): Promise<
    TaskEither.TaskEither<
      DataSourceError | ParseError | TodoNotFoundError,
      Todo
    >
  >;
  create(
    data: CreateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
  update(
    id: string,
    data: UpdateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
  remove(
    id: string,
  ): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
};

现在,我的存储库和数据源开始与它们的错误类型耦合。但是,存储库现在开始在此处添加一些自己的错误类型,例如(当将 DTO 解析到域实体失败时)和(当 findOne 函数返回 null 时 - 尽管使用 Option 可能更好)。PARSE_ERRORTODO_NOT_FOUND

我想这还不错,但我们只有两个调用深度,我的存储库不应该处理对用户出错的响应,我应该让这些错误将调用链传递回控制器,以便控制器可以使用适当的响应代码/文本进行响应。

在这种情况下,我认为它很容易处理,因为调用堆栈很简单,但是如果控制器最终调用了多个服务和/或存储库,并且我最终不得不键入所有接口及其可能的左错误类型,该怎么办?

我是否错误地处理了这一点?或者这是意料之中的?

另一种方法是混合使用这两种方法,使用 for 异常,并在控制器上捕获它们。并且使用 for 错误,我可以在调用堆栈的早期处理。try/catchEither

TypeScript 错误处理 函数式编程 FP-TS

评论

0赞 Souperman 11/6/2023
我不确定我能否制定一个连贯的答案,但我会尽力提供帮助作为评论。为了避免左侧错误代码出现巨大的联合类型,您可以做的一件事是创建一个 API 将扩展的自定义类。然后,你总是可以返回左边的基类,并使用 OOP 来构建你的错误,同时仍然利用 FP 样式来表达系统的核心逻辑。当出现新错误时,您不必更改 API,因为新错误可能只是自定义错误类型的新子类。Error
1赞 Souperman 11/6/2023
有点题外话,但应该是 的 FP 等价物。它的接口是返回 promise 的函数类型。这允许您在实际执行生成 .退货对我来说有点不对劲。感觉像回来了。我认为您的 API 可以更新为仅返回 ,而不是承诺。 应该可以分配给(这应该让你了解如何塑造你的函数实现)。TaskPromisePromisePromise<Task<...>>Promise<Promise<...>>TaskEither() => Promise<Either<A, B>>TaskEither
1赞 leepowell 11/6/2023
@Souperman - 谢谢,好地方,会调整。TaskPromise
0赞 cdimitroulas 11/7/2023
我个人喜欢将“错误”与“异常”分开。错误是与域相关的错误,在类型系统中必须存在这些错误,以便更高级别的代码可以单独处理它们并定制响应。异常是意外错误,通常您只会记录并返回 500。异常不需要使用 Either 进行处理,只需由顶层的泛型错误处理程序引发和捕获即可。不确定这是否对您有任何帮助:)

答:

0赞 Awais khan 11/16/2023 #1

您已经深入研究了函数式编程中错误处理的一个基本方面,尤其是使用 Either monad。虽然类型中的详细程度和增加的耦合最初可能看起来势不可挡,但在 FP 中,在每一层显式处理错误类型是很常见的。

你对在调用堆栈中传播错误的担忧是有道理的。正如您正确提到的,控制器不一定应该处理低级错误细节;它更适合在请求上下文中解释错误并制定适当的响应。

一个潜在的改进可能是引入常见的错误并集类型或更精细的错误处理策略。例如:

type AppError =
  | DataSourceError
  | ParseError
  | TodoNotFoundError
  // Add more specific errors as necessary

// In the repository interface
export type TodoRepository = {
  findAll(): Promise<TaskEither.TaskEither<AppError, ReadonlyArray<Todo>>>;
  findOne(id: string): Promise<TaskEither.TaskEither<AppError, Todo>>;
  // ...
}

这种合并可能会减少类型中的一些冗长,并提供一种更集中的方法来处理错误。此外,随着应用程序的增长,考虑显式错误处理与其引入的复杂性之间的权衡至关重要。

关于将 try/catch 与 Either 混合使用,这是一种有效的方法。您可以为真正意外的异常情况(如灾难性故障)保留 try/catch,同时将 Either 用于域中的可恢复错误或预期故障方案。

混合使用这两种方法可以在 FP 的显式错误处理和传统的基于异常的处理之间提供平衡,但在两者之间保持清晰的边界对于代码库的清晰度和一致性至关重要。

请记住,在错误处理的显性和代码维护的实用性之间找到适当的平衡是关键。每个项目可能有不同的要求和权衡,因此相应地调整错误处理策略是一种很好的做法。