mcpField
F
flutter-error-handling

flutter-error-handling

github.com/junsuk5Development2444 installsUpdated 24w ago
Repository

About flutter-error-handling

A Flutter development skill for representing expected failures with typed `Result` values instead of propagating exceptions. It guides developers through error modeling and handling across data, domain, and presentation layers.

Key features

  1. 01Typed `Result` success and error cases
  2. 02Exhaustive matching with sealed types
  3. 03Feature-specific error enums
  4. 04Data-layer exception conversion
  5. 05Stream events or state for UI errors

Use cases

  1. 01Model network and feature failures in UseCases
  2. 02Ensure ViewModels handle every result case
  3. 03Show one-time errors or persistent error banners

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/junsuk5/survival-flutter-skills --skill flutter-error-handling

After installing, type/flutter-error-handlingin Claude Code to confirm it loaded.

Details

From the project README

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

핵심 철학

예상 가능한 실패에는 예외를 던지지 않는다. 대신 타입으로 표현된 Result 를 반환한다. 이렇게 하면 호출자가 실패 케이스를 타입 시스템으로 강제로 다루게 되어, 런타임에 놓치는 UI 에러 경로가 사라진다.

예외는 프레임워크/플랫폼이 던진 것을 가장 낮은 레이어(Data)에서 잡아 Result.error(...) 로 변환하는 용도로만 쓴다. UseCase, ViewModel, Screen 은 더 이상 try/catch 를 보지 않는다.


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

Error 마커 인터페이스
// lib/core/domain/error/error.dart
abstract interface class Error {}

모든 커스텀 에러 타입은 이 Error 를 구현한다. Result 의 E 는 반드시 extends Error 이므로 Dart 표준 Exception 과 섞이지 않는다.

Result<D, E>
// lib/core/domain/error/result.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'error.dart';

part 'result.freezed.dart';

@freezed
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 컴파일러가 강제한다. 케이스를 빠뜨리면 컴파일 경고가 뜬다.


기능별 에러 정의

에러는 enum + implements Error 로 정의한다. 각 값은 사용자에게 보여줄 한국어 메시지를 toString() 에 둔다.

// lib/core/domain/error/network_error.dart
enum NetworkError implements Error {
  requestTimeout,
  noInternet,
  serverError,
  unknown;

  @override
  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 는 정확히 한 가지 에러만 담는다. 여러 조건을 동시에 알려줘야 한다면 그것은 도메인 설계 문제다.


UseCase / Repository 반환 타입

// 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).

ViewModel에서 소비하기

sealed 타입이므로 타입 파라미터를 명시해 switch 에서 패턴 매칭한다. 이 프로젝트는 다음 형태를 정석으로 쓴다 (home_view_model.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로 전달하는 방식

두 가지 표준 패턴이 있다.

1) 한 번 보여주는 스낵바/토스트 — StreamController 이벤트
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;

Root 위젯이 eventStream 을 listen 해서 ScaffoldMessenger.showSnackBar 를 호출한다. 상태에 담으면 리빌드마다 반복되므로 이벤트로 내보낸다.

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

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


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

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 가 보이면 경고 신호다.

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

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

체크리스트

  • 새 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 가 떠돌지 않는다

안티 패턴

  • ❌ Future<List<Recipe>> 를 그대로 반환하고 실패를 throw 로 전달 → 호출자가 실패를 잊는다.
  • ❌ Result<List<Recipe>, Exception> 처럼 Exception 을 에러 타입으로 사용 → Error 마커의 의미가 사라진다.
  • ❌ 한 Result 에 리스트로 여러 에러를 담기 → 모델이 복잡해지고 UI 분기가 폭발한다.
  • ❌ switch 에서 default: 로 퉁치기 → 새 에러 값 추가 시 컴파일러가 경고해 주는 안전망을 날려버린다.

Comments

Markdown supported