freeCodeCamp.org

How to Use DartExceptor: A Lighter Way to Handle Errors in Dart 3

8.5内容质量

TL;DR · AI 摘要

DartExceptor 提供了一种更轻量的错误处理方式,通过类型系统和结构化错误类型提升 Dart 3 的错误处理能力。

核心要点

  • 使用 DartExceptor 可以通过类型系统明确错误类型,避免错误被忽略。
  • DartExceptor 的 API 设计为四个方法,每个方法只负责一项任务,提升代码可读性。
  • DartExceptor 与 Clean Architecture 结合,使错误处理更符合分层架构原则。

结构提纲

按章节快速跳转。

  1. 传统错误处理方式存在诸多问题,如错误不可见、编译器无法帮助检测错误等。

  2. 上一篇文章介绍了使用 Dart Records 和 sealed Result 类型来改进错误处理。

  3. 即使使用了 sealed Result 类型,仍然存在错误处理不够明确的问题。

  4. DartExceptor 通过结构化错误类型和 API 设计,使错误处理更清晰、更安全。

  5. DartExceptor 的核心类型 AppResult<T> 提供了 AppSuccess 和 AppFailure 两个子类。

  6. DartExceptor 的 API 设计为四个方法,每个方法只负责一项任务,提升代码可读性。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • DartExceptor 错误处理
    • 问题
      • 错误不可见
      • 编译器无法帮助检测错误
      • 错误处理不明确
    • 解决方案
      • 使用 sealed Result 类型
      • 结构化错误类型
      • API 设计为四个方法

金句 / Highlights

值得收藏与分享的关键句。

  • Traditional error handling in Dart can lead to invisible failures, compiler helplessness, and indiscriminate error catching.

    第 1 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • DartExceptor introduces a sealed Result type, AppResult<T>, which replaces nullable-field records with two structurally distinct subclasses.

    第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • DartExceptor's API is designed with four methods, each with one job, making error handling more readable and maintainable.

    第 5 段

    ⬇︎ 下载 PNG𝕏 分享到 X
#Dart#错误处理#Flutter#Clean Architecture
打开原文

如何使用 DartExceptor:一种更轻量的方式来处理 Dart 3 中的错误

2026年6月17日

/

#Dart

Oluwaseyi Fatunmole

如果你已经使用 Flutter 有一段时间了,你可能已经写过下面这段代码:

code
try {
  final user = await repo.getUser();
  print(user.name);
} catch (e) {
  print('Something went wrong: $e');
}

这段代码可以编译,可以发布。但六个月后,用户可能会报告一个 bug,他们面对的是一个空白屏幕,因为某个地方的 catch (e) 把真正的错误给吞掉了。

这段代码看起来无害,但它有三个问题,只有在压力之下才会显现出来。

首先,错误在签名中是不可见的。无论 repo.getUser() 返回什么,都无法告诉你网络中断、令牌过期或响应格式错误时会发生什么。你只有通过阅读实现代码,或者在生产环境中遇到 bug 才能发现。

其次,编译器帮不上忙。如果同事在代码库的其他地方忘了添加 try/catch,应用仍然可以正常编译。没有任何警告。崩溃发生在运行时,面对的是真正的用户,而不是你在构建时看到的。

第三,catch (e) 会无差别地捕获一切。一个拼写错误、一个空引用、一个真正的网络错误和一个格式错误的 JSON 响应都会被放到同一个代码块中。你无法在不检查错误字符串的情况下区分它们,而这种方式很脆弱,因为只要消息一改变,就会出问题。

总的来说,每一条错误路径都变成了函数作者和调用者之间的社会契约,而不是类型系统强制执行的规则。社会契约在高压下、在大型团队中、在凌晨两点的事件处理中都会破裂。

几周前,我写了一篇《Dart 中的高级错误处理:记录、结果类型、单子和冻结异常》,详细介绍了如何使用记录、密封结果类型、单子模式、dartz 和冻结异常来解决这个问题,使错误变得类型化、可见且无法忽视。

本文旨在独立存在,因此在我们重新拾起这个话题之前,先快速回顾一下前一篇文章的结论。

我们将涵盖的内容:

  • 回顾:前一篇文章的结尾
  • 模式之后的问题
  • DartExceptor 的工作原理
  • 核心类型
  • API:四个方法,每个方法只做一件事
  • 它在整洁架构中的位置
  • 为什么不直接使用 dartz?
  • 试用一下

回顾:前一篇文章的结尾

那篇文章通过了多个层次,每一层都解决了上一层的限制。

它从 Dart 记录开始,作为最简单的修复方式,一个带有可空字段的成功和失败的类型化元组:

code
typedef Result<E, T> = ({E? e, T? data});

这已经比裸异常更好,因为返回类型现在允许函数失败。

但记录确实有一个真正的限制。你很容易忘记检查哪个字段被填充了,而且没有方法可以在不手动解包它的情况下转换结果。

这个缺口导致了正确的密封结果类型 AppResult<T>,它用两个结构上不同的子类 AppSuccess 和 AppFailure 替换了可空字段记录,并添加了一个 when() 方法,强制处理这两种情况:

code
sealed class AppResult<T> {
  const AppResult();

  R when<R>({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  });
}

class AppSuccess<T> extends AppResult<T> {
  const AppSuccess(this.value);
  final T value;
code
  @override
  R when<R>({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  }) => success(value);
}

class AppFailure<T> extends AppResult<T> {
  const AppFailure(this.error);
  final AppError error;

  @override
  R when<R>({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  }) => failure(this);
}

由于 AppResult 是密封的,编译器强制执行穷尽性检查。你真的无法像使用记录或 try/catch 那样忘记 failure 分支。

从那里开始,文章将 AppResult 扩展为一个真正的 Monad,通过添加 map 和 flatMap,使得结果可以在不离开包装器的情况下进行转换和链式调用,并引入了 dartz 的 Either 作为更传统的函数式编程等价物,供希望使用该术语的团队使用。最后,文章引入了基于 Freezed 的类型化异常,这样即使在失败的一侧,也携带结构化、可模式匹配的数据,而不是一个裸字符串。

最终,这个模式在全栈中看起来是这样的:一个密封的结果类型、结构化的异常,以及用于转换的 map / flatMap,这些在仓库、领域和展示层中保持一致地连接。

如果你想了解完整的推导过程,为什么每个层被添加,dartz 的集成,以及 Freezed 异常的设置,那篇文章都深入地涵盖了这些内容。这里所遵循的只是上述的形状,而不是到达那里的旅程。

模式之后的问题

在我发表那篇文章之后发生了以下事情。

每次我开始一个新项目时,我发现自己总是在做同样的事情:重新创建密封的 Result 类,重新编写 Ok 和 Err,重新实现 map、flatMap 和其余部分。从项目到项目复制大约 150 行的相同代码,对小细节进行调整,偶尔因为忘记上次命名了什么而在项目之间引入不一致。

模式是正确的。重复是不必要的。

一个每次都要重写模式的模式不是一个模式,而是一种负担。所以我将它打包成一个库。

DartExceptor 的工作原理

DartExceptor 是一个轻量级、零依赖的 Dart 3 包,它实现了前一篇文章中提到的精确模式,包括 Trace<T, E>、Ok、Err,以及一组小而有意的 Monad 操作,作为可重用的包。

不需要 dartz、不需要 Freezed,也不需要 build_runner。只有 Trace<T, E>、两个实现和四个方法。

code
dependencies:
  dart_exceptor: ^1.1.2
code
import 'package:dart_exceptor/dart_exceptor.dart';

这就是全部的设置。

核心类型

DartExceptor 中的每个操作都返回一个 Trace<T, E>:

  • T 是成功类型
  • E 是错误类型

Trace 恰好有两个实现:

code
return Ok(user);                                    // 成功
return Err(AppException(code: 404, e: 'Not found')); // 失败

你从不直接构造 Trace。你返回 Ok 或 Err,并在其他所有地方以 Trace 为对象进行编程。函数签名现在如实说明了可能发生的情况:

code
Future<Trace<User, AppException>> getUser(String id);

任何阅读该签名的人都会立即知道这可能会成功返回一个 User,或者失败返回一个 AppException。六个月后不会有意外。

API:四个方法,每个方法只做一件事

如果前一篇文章中的 Result 类型有 map、flatMap 和一个用于模式匹配的 when(),DartExceptor 将采用相同的形状,并将其精炼为四个专注的方法。

split,退出点

split 是你离开 Trace 世界的地方。两个处理器都是必需的,因此你不能不小心忽略失败路径。

code
result.split(
  data: (user) => print(user.name),
  e: (e) => print(e.message),
);

map,提取并转换成功结果

map 会从 Ok 中提取值并允许你直接对其进行转换:

code
final activeUsers = result.map(
  data: (users) => users.where((u) => u.isActive).toList(),
);

mapError,提取并转换失败结果

这是 map 的镜像,用于错误处理。当跨越架构边界时,它非常有用,这时你的数据层的异常类型可能与你的领域层不同:

code
final domainError = result.mapError(
  e: (e) => AppException(code: e.statusCode, e: e.toString()),
);

bind<B>,连接返回 Trace 的操作

这是真正执行操作的部分。bind<B> 允许你连接返回 Trace 的操作,每一步都转换成功类型。如果任何一步失败,所有后续操作会自动被跳过。

code
result
    .bind<User>(
      n: (users) {
        try {
          return Ok(users.firstWhere((u) => u.id == id));
        } catch (e) {
          return Err(AppException(code: 404, e: 'User not found'));
        }
      },
    )
    .bind<String>(n: (user) => Ok(user.firstName))
    .split(
      data: (name) => print('User: $name'),
      e: (e) => print('Error: ${e.e}'),
    );

List<User> 变成 User 变成 String。每个 bind<B> 都会转换类型,编译器会检查每一步,链式中的任何失败都会直接跳转到 split 中的 e 处理器。这是前一篇文章中 flatMap 讨论的逻辑结论。

在 Clean 架构中的位置

原始文章中的模式始终不仅仅是关于语法,而是关于在各层之间使失败可见。DartExceptor 完美地融入了这种结构,无需任何修改:

code
// 数据层
abstract class DataSource {
  Future<Trace<List<User>, AppException>> getAllUsers();
}

// 仓库层
abstract class IUserRepository {
  Future<Trace<List<User>, AppException>> getAllUsers();
}

// 用例层
class UserUseCase {
  Future<Trace<List<User>, AppException>> getAllUsers() => repository.getAllUsers();
}

// 表示层
void loadUsers() async {
  final result = await useCase.getAllUsers();

  result.split(
    data: (users) => print('Loaded ${users.length} users'),
    e: (e) => print('Failed: ${e.e}'),
  );
}

相同的层,相同的分离,相同类型的失败路径,只是无需每次重新构建基础。

为什么不直接使用 dartz?

前一篇文章详细介绍了 dartz 的 Either,如果你的团队熟悉其 API 表面,并且不介意依赖项的开销,它确实是一个非常可靠的选择。

DartExceptor 的存在是为了更狭窄的情况,即当你想要使用结果类型模式,而无需导入基于 Haskell 风格函数式编程惯例的库。没有 Left / Right,没有 fold,也没有传递依赖。只有 Trace、Ok、Err,以及四个直接映射到前一篇文章中模式实际使用方式的方法。

| DartExceptor | dartz | |--------------|-------| | 依赖项 | 零 | 多个 | | Dart 3 原生支持 | 是 | 否 | | API 表面 | 4 个方法 | 大 | | 需要 Haskell 概念 | 是 | 否 | | 类型安全链式(bind<B>) | 是 | 是(flatMap) |

尝试一下

DartExceptor 已经发布在 pub.dev 上:

包:pub.dev/packages/dart_exceptor 源代码:GitHub

如果你已经阅读了上一篇文章,并且自己实现了一个类似的功能,我真心想听听你的版本与之相比如何。如果 DartExceptor 能够帮你避免再次重写这个模式,那么在 GitHub 上给一个星标将会有很大帮助。

我是拥有 7 年以上经验的移动工程负责人和高级软件工程师,专注于构建和领导可扩展、企业级跨平台移动应用的交付。我在系统设计方面有丰富的经验,涵盖从架构到部署的整个流程,特别关注性能、可扩展性和可维护的代码。我曾领导工程团队,提高交付效率、代码质量和团队协作,同时指导开发人员在技术和职业上不断成长。我的核心技术栈包括 Flutter、Dart、.NET 和 .NET Core,有将移动系统与强大的后端架构集成的经验。除了交付工作,我还开发并发布 Flutter 插件,为开源项目做贡献,并撰写技术内容,帮助简化复杂的工程概念。我处于实际工程与技术领导的交汇点,专注于构建具有影响力的产品,并赋能高效团队。

如果你读到这里,请感谢作者,以表达你对他们的关心。说声谢谢

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发人员。立即开始

ADVERTISEMENT