flutter-error-handling

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Flutter 에러 처리 — Result<D, E>

Flutter 错误处理 — Result<D, E>

핵심 철학

核心哲学

예상 가능한 실패에는 예외를 던지지 않는다. 대신 타입으로 표현된
Result
를 반환한다. 이렇게 하면 호출자가 실패 케이스를 타입 시스템으로 강제로 다루게 되어, 런타임에 놓치는 UI 에러 경로가 사라진다.
예외는 프레임워크/플랫폼이 던진 것을 가장 낮은 레이어(Data)에서 잡아
Result.error(...)
로 변환하는 용도로만 쓴다. UseCase, ViewModel, Screen 은 더 이상 try/catch 를 보지 않는다.

对于可预见的失败,不抛出异常。而是返回以类型形式表达的
Result
。这样可以强制调用方通过类型系统处理失败场景,消除运行时遗漏的UI错误路径。
异常仅用于在最底层(数据层)捕获框架/平台抛出的异常,并转换为
Result.error(...)
。UseCase、ViewModel、Screen层不再需要try/catch。

기반 타입 (
lib/core/domain/error/
)

基础类型 (
lib/core/domain/error/
)

Error 마커 인터페이스

Error 标记接口

dart
// lib/core/domain/error/error.dart
abstract interface class Error {}
모든 커스텀 에러 타입은 이
Error
를 구현한다.
Result
E
는 반드시
extends Error
이므로 Dart 표준
Exception
과 섞이지 않는다.
dart
// lib/core/domain/error/error.dart
abstract interface class Error {}
所有自定义错误类型都需要实现这个
Error
接口。
Result
E
必须
extends Error
,因此不会与Dart标准
Exception
混淆。

Result<D, E>

Result<D, E>

dart
// lib/core/domain/error/result.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'error.dart';

part 'result.freezed.dart';


sealed class Result<D, E extends Error> with _$Result<D, E> {
  const factory Result.success(D data) = ResultSuccess;
  const factory Result.error(E error) = ResultError;
}
중요:
sealed
로 선언했기 때문에
switch
에서 모든 케이스를 Dart 컴파일러가 강제한다. 케이스를 빠뜨리면 컴파일 경고가 뜬다.

dart
// lib/core/domain/error/result.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'error.dart';

part 'result.freezed.dart';


sealed class Result<D, E extends Error> with _$Result<D, E> {
  const factory Result.success(D data) = ResultSuccess;
  const factory Result.error(E error) = ResultError;
}
重要提示:由于使用
sealed
声明,Dart编译器会强制
switch
处理所有场景。遗漏场景时会触发编译警告。

기능별 에러 정의

功能专属错误定义

에러는 enum +
implements Error
로 정의한다. 각 값은 사용자에게 보여줄 한국어 메시지를
toString()
에 둔다.
dart
// lib/core/domain/error/network_error.dart
enum NetworkError implements Error {
  requestTimeout,
  noInternet,
  serverError,
  unknown;

  
  String toString() => switch (this) {
        NetworkError.requestTimeout => '요청 시간이 초과되었습니다',
        NetworkError.noInternet => '인터넷 연결을 확인해 주세요',
        NetworkError.serverError => '서버에 문제가 발생했습니다',
        NetworkError.unknown => '알 수 없는 문제가 발생했습니다',
      };
}
공유 에러(
NetworkError
)는
lib/core/domain/error/
에, feature 전용 에러는
lib/domain/error/<feature>_error.dart
에 둔다. 예:
BookmarkError
,
NewRecipeError
.
다중 에러는 표현하지 않는다.
Result
는 정확히 한 가지 에러만 담는다. 여러 조건을 동시에 알려줘야 한다면 그것은 도메인 설계 문제다.

错误通过**enum +
implements Error
**来定义。每个值在
toString()
中存放要展示给用户的韩语消息。
dart
// lib/core/domain/error/network_error.dart
enum NetworkError implements Error {
  requestTimeout,
  noInternet,
  serverError,
  unknown;

  
  String toString() => switch (this) {
        NetworkError.requestTimeout => '요청 시간이 초과되었습니다',
        NetworkError.noInternet => '인터넷 연결을 확인해 주세요',
        NetworkError.serverError => '서버에 문제가 발생했습니다',
        NetworkError.unknown => '알 수 없는 문제가 발생했습니다',
      };
}
公共错误(
NetworkError
)放在
lib/core/domain/error/
目录下,功能专属错误放在
lib/domain/error/<feature>_error.dart
目录下。例如:
BookmarkError
NewRecipeError
不支持多错误表达。一个
Result
只能包含一种错误。如果需要同时告知多个条件,这属于领域设计问题。

UseCase / Repository 반환 타입

UseCase / Repository 返回类型

dart
// UseCase 시그니처
Future<Result<List<String>, NetworkError>> execute();
Future<Result<List<Recipe>, BookmarkError>> execute(int recipeId);
  • 성공 데이터 타입
    D
    와 에러 타입
    E
    를 명시한다.
  • Data 레이어에서 네트워크/DB 예외를 catch 해
    NetworkError.unknown
    같은 값으로 변환한다.
  • UseCase는 여러 Repository 에러를 자기 feature 에러로 매핑해서 반환한다 (예: 북마크 저장 실패 시
    BookmarkError.saveFailed
    ).

dart
// UseCase 签名
Future<Result<List<String>, NetworkError>> execute();
Future<Result<List<Recipe>, BookmarkError>> execute(int recipeId);
  • 明确指定成功数据类型
    D
    和错误类型
    E
  • 在数据层捕获网络/数据库异常,转换为
    NetworkError.unknown
    等值。
  • UseCase将多个Repository错误映射为自身功能的错误后返回(例如:书签保存失败时返回
    BookmarkError.saveFailed
    )。

ViewModel에서 소비하기

在ViewModel中消费

sealed 타입이므로 타입 파라미터를 명시해
switch
에서 패턴 매칭한다. 이 프로젝트는 다음 형태를 정석으로 쓴다 (
home_view_model.dart
참조).
dart
void _fetchCategories() async {
  final result = await _getCategoriesUseCase.execute();

  switch (result) {
    case ResultSuccess<List<String>, NetworkError>():
      _state = state.copyWith(
        categories: result.data,
        selectedCategory: 'All',
      );
      notifyListeners();

    case ResultError<List<String>, NetworkError>():
      switch (result.error) {
        case NetworkError.requestTimeout:
        case NetworkError.noInternet:
        case NetworkError.serverError:
        case NetworkError.unknown:
          _eventController.add(result.error);
      }
  }
}
왜 이렇게 쓰나:
  • ResultSuccess<D, E>()
    /
    ResultError<D, E>()
    를 적어야 제네릭이 유지되고
    result.data
    /
    result.error
    의 구체 타입이 살아 있다.
  • 안쪽
    switch (result.error)
    는 모든 enum 케이스를 강제로 나열하게 만들어, 새 에러가 추가될 때 누락된 처리 지점을 컴파일러가 알려준다.

由于是密封类型,需要明确指定类型参数,在
switch
中进行模式匹配。本项目将以下形式作为标准写法(参考
home_view_model.dart
)。
dart
void _fetchCategories() async {
  final result = await _getCategoriesUseCase.execute();

  switch (result) {
    case ResultSuccess<List<String>, NetworkError>():
      _state = state.copyWith(
        categories: result.data,
        selectedCategory: 'All',
      );
      notifyListeners();

    case ResultError<List<String>, NetworkError>():
      switch (result.error) {
        case NetworkError.requestTimeout:
        case NetworkError.noInternet:
        case NetworkError.serverError:
        case NetworkError.unknown:
          _eventController.add(result.error);
      }
  }
}
为什么这样写:
  • 必须填写
    ResultSuccess<D, E>()
    /
    ResultError<D, E>()
    才能保留泛型,确保
    result.data
    /
    result.error
    的具体类型有效。
  • 内层
    switch (result.error)
    会强制列出所有enum场景,新增错误时编译器会提示遗漏的处理点。

에러를 UI로 전달하는 방식

错误传递到UI的方式

두 가지 표준 패턴이 있다.
有两种标准模式。

1) 한 번 보여주는 스낵바/토스트 —
StreamController
이벤트

1) 一次性显示的Snackbar/Toast —
StreamController
事件

dart
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;
Root 위젯이
eventStream
을 listen 해서
ScaffoldMessenger.showSnackBar
를 호출한다. 상태에 담으면 리빌드마다 반복되므로 이벤트로 내보낸다.
dart
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;
根Widget监听
eventStream
并调用
ScaffoldMessenger.showSnackBar
。如果将错误存入状态,会导致每次重建都重复显示,因此以事件形式抛出。

2) 지속 상태(에러 배너) — State 필드

2) 持续状态(错误横幅)— State字段

에러 화면 자체를 그려야 한다면
State
NetworkError? error
필드를 두고
copyWith(error: ...)
로 반영한다. 사용자가 닫거나 재시도하면
error: null
로 초기화한다.

如果需要绘制错误界面本身,可以在
State
中添加
NetworkError? error
字段,通过
copyWith(error: ...)
更新状态。用户关闭或重试时重置为
error: null

Data 레이어 — 예외를 Result로 바꾸는 지점

数据层 — 将异常转换为Result的节点

dart
Future<Result<List<RecipeDto>, NetworkError>> getRecipes() async {
  try {
    final raw = await _recipeDataSource.getRecipes();
    return Result.success(raw.map(RecipeDto.fromJson).toList());
  } on SocketException {
    return const Result.error(NetworkError.noInternet);
  } on TimeoutException {
    return const Result.error(NetworkError.requestTimeout);
  } catch (_) {
    return const Result.error(NetworkError.unknown);
  }
}
원칙:
  • 예외가 발생하는 레이어가 곧 잡는 레이어다. 플랫폼/HTTP 예외는 Data에서, 도메인 검증 실패는 Domain에서
    Result.error
    로 변환한다.
  • Presentation 에는 예외가 절대 올라오지 않게 한다. ViewModel 의
    try/catch
    가 보이면 경고 신호다.

dart
Future<Result<List<RecipeDto>, NetworkError>> getRecipes() async {
  try {
    final raw = await _recipeDataSource.getRecipes();
    return Result.success(raw.map(RecipeDto.fromJson).toList());
  } on SocketException {
    return const Result.error(NetworkError.noInternet);
  } on TimeoutException {
    return const Result.error(NetworkError.requestTimeout);
  } catch (_) {
    return const Result.error(NetworkError.unknown);
  }
}
原则:
  • 异常发生的层级就是捕获的层级。平台/HTTP异常在数据层处理,领域验证失败在领域层转换为
    Result.error
  • 绝对不允许异常传递到Presentation层。如果在ViewModel中看到
    try/catch
    ,就是危险信号。

어떤 에러 타입을 쓸지 결정표

错误类型决策表

시나리오에러 타입위치
네트워크 호출 실패
NetworkError
core/domain/error/
로컬 저장소/DB 실패
LocalError
(필요시 신규)
core/domain/error/
기능 전용 실패 (북마크 저장 실패 등)
BookmarkError
같은 enum
lib/domain/error/
여러 DataSource 를 묶는 Repository상위 에러 타입 (
NetworkError
또는 feature error)
해당 feature

场景错误类型位置
网络调用失败
NetworkError
core/domain/error/
本地存储/数据库失败
LocalError
(必要时新增)
core/domain/error/
功能专属失败(如书签保存失败)类似
BookmarkError
的enum
lib/domain/error/
整合多个DataSource的Repository上层错误类型(
NetworkError
或功能专属错误)
对应功能模块

체크리스트

检查清单

  • 새 feature의 실패 유형을 enum +
    implements Error
    로 정의했다
  • UseCase 시그니처가
    Future<Result<D, FooError>>
    로 타입 파라미터를 명시한다
  • ViewModel에서
    switch (result)
    ResultSuccess<D, E>()
    /
    ResultError<D, E>()
    를 모두 처리한다
  • 에러 enum 의 모든 값이 내부
    switch (result.error)
    에서 나열되어 있다
  • Data 레이어가 플랫폼 예외를 catch 해
    Result.error
    로 변환한다
  • Presentation 어디에도
    try/catch
    가 떠돌지 않는다

  • 新功能的失败类型已通过**enum +
    implements Error
    **定义
  • UseCase签名以
    Future<Result<D, FooError>>
    形式明确指定类型参数
  • ViewModel中的
    switch (result)
    已处理
    ResultSuccess<D, E>()
    /
    ResultError<D, E>()
    所有情况
  • 错误enum的所有值都已在内部
    switch (result.error)
    中列出
  • 数据层已捕获平台异常并转换为
    Result.error
  • Presentation层中不存在游离的
    try/catch

안티 패턴

反模式

  • Future<List<Recipe>>
    를 그대로 반환하고 실패를
    throw
    로 전달 → 호출자가 실패를 잊는다.
  • Result<List<Recipe>, Exception>
    처럼
    Exception
    을 에러 타입으로 사용 →
    Error
    마커의 의미가 사라진다.
  • ❌ 한
    Result
    에 리스트로 여러 에러를 담기 → 모델이 복잡해지고 UI 분기가 폭발한다.
  • switch
    에서
    default:
    로 퉁치기 → 새 에러 값 추가 시 컴파일러가 경고해 주는 안전망을 날려버린다.
  • ❌ 直接返回
    Future<List<Recipe>>
    并通过
    throw
    传递失败 → 调用方可能遗漏失败处理
  • ❌ 像
    Result<List<Recipe>, Exception>
    一样使用
    Exception
    作为错误类型 → 失去
    Error
    标记的意义
  • ❌ 在一个
    Result
    中以列表形式包含多个错误 → 模型变得复杂,UI分支爆炸式增长
  • ❌ 在
    switch
    中用
    default:
    统一处理 → 丢弃了新增错误值时编译器给出警告的安全保障