← Flutter Reference

Flutter Error Handling: Either<Failure, T> and Beyond

Reliability · Advanced

By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.

Error handling in Flutter has two layers: the domain layer (recoverable failures your code handles gracefully) and the platform layer (unexpected crashes you cannot recover from but must report). Mixing them up — catching everything in one big try/catch or throwing exceptions across layers — leads to bugs that are hard to trace and crashes that go unreported.

At iStoria, we maintain a 99.9% crash-free rate across 5M+ users. The pattern that makes this possible is Either<Failure, T> for recoverable errors and structured crash reporting (Sentry) for everything else. Each layer has a clear responsibility: repositories map exceptions to domain Failures, BLoCs fold over Either, and the platform layer catches the unexpected.

This guide covers the full pattern — from defining Failure types to wiring up async error zones and crash reporting — as we use it in production.

Two Kinds of Errors

Recoverable errors are part of your business domain. The network went down, the cache was empty, the user entered invalid data. These are expected, the app handles them, and the user sees a friendly message. These should be Either<Failure, T>, not exceptions.

Unrecoverable errors are bugs. A null where there should not be one, an index out of bounds, a state that should never occur. The app cannot handle these gracefully. These should crash (or be caught by an error zone) and be reported to Sentry so you can fix them.

The mistake most teams make is treating all errors as the same kind. They catch every exception in a giant try/catch, suppressing bugs and hiding real crashes. Or they throw exceptions across layers, making the code path unpredictable and untestable.

The Either<Failure, T> Pattern

Either<L, R> from the fpdart (or dartz) package represents a value that is one of two types. By convention, Left holds the failure and Right holds the success value.

// A repository method returns Either<Failure, T>
Future<Either<Failure, List<Lesson>>> getLessons(String courseId);

// The caller MUST handle both cases explicitly:
final result = await repository.getLessons(courseId);
result.fold(
  (failure) => emit(LessonError(failure.message)),
  (lessons) => emit(LessonLoaded(lessons)),
);

The power of this pattern is that you cannot forget to handle the failure case. With exceptions, a caller can forget the try/catch and the exception propagates unpredictably. With Either, the type system forces you to handle both branches.

Defining Failures

Failures are part of the domain. They represent business-meaningful error states:

abstract class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  final int? statusCode;
  const ServerFailure([super.message = 'Server error', this.statusCode]);
}

class CacheFailure extends Failure {
  const CacheFailure([super.message = 'Cache error']);
}

class OfflineFailure extends Failure {
  const OfflineFailure() : super('You are offline. Please check your connection.');
}

class ValidationFailure extends Failure {
  final Map<String, String> fieldErrors;
  const ValidationFailure(this.fieldErrors) : super('Validation failed');
}

The key principle: Failures are domain concepts, not infrastructure exceptions. DioError, DriftException, SocketException — these are data-layer concerns. The repository catches them and maps them to domain Failures. The BLoC never sees a DioError.

Where Exceptions Still Belong

Not everything should be an Either. Some operations are genuinely exception-based:

1. Programming errors — null dereference, index out of bounds, assertion failures. These should crash and be reported, not be wrapped in Either. 2. Third-party SDKs — some packages throw exceptions. Wrap their calls in your repository and map exceptions to Failures. 3. Truly unexpected states — if a repository method receives a response that violates an invariant, throw. This is a bug, not a recoverable error.

The Repository: Mapping Exceptions to Failures

The repository is the translation point. It catches infrastructure exceptions and converts them to domain Failures:

@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
  if (!await networkInfo.isConnected) {
    return const Left(OfflineFailure());
  }
  try {
    final response = await remoteDatasource.fetchLessons(courseId);
    return Right(response.map((m) => m.toEntity()).toList());
  } on DioError catch (e) {
    return Left(ServerFailure(e.message ?? 'Unknown error', e.response?.statusCode));
  } on CacheException catch (e) {
    return Left(CacheFailure(e.message));
  } catch (e) {
    // Unexpected — report to Sentry, then return a generic failure
    await Sentry.captureException(e);
    return Left(UnexpectedFailure(e.toString()));
  }
}

The last catch block is important. It catches truly unexpected exceptions, reports them, and returns a failure. This prevents the app from crashing on unexpected exceptions while still surfacing them in Sentry.

Async Error Zones and Crash Reporting

Flutter has error zones for isolating async failures. The two zones you need to know:

1. runZonedGuarded — wraps your entire app and catches uncaught async errors. This is where you configure Sentry to report crashes. 2. FlutterError.onError — catches Flutter framework errors (rendering, layout, widget build failures).

In main():

void main() {
  runZonedGuarded(() {
    WidgetsFlutterBinding.ensureInitialized();
    FlutterError.onError = (details) {
      FlutterError.presentError(details);
      Sentry.captureException(details.exception, stackTrace: details.stack);
    };
    runApp(MyApp());
  }, (error, stack) {
    Sentry.captureException(error, stackTrace: stack);
  });
}

This ensures that every uncaught error — sync or async, framework or Dart — reaches Sentry. Combined with Either<Failure, T> for recoverable errors, this gives you a comprehensive error strategy: expected failures are handled in the UI, unexpected crashes are reported.

Crash-Free Rate as a Metric

At iStoria, we track crash-free rate as a first-class metric. The target is 99.9% — meaning fewer than 0.1% of sessions crash. Achieving this requires:

  • Either<Failure, T> on every repository method so recoverable errors never crash the app.
  • Sentry on every uncaught exception so you know about crashes immediately.
  • A triage workflow: every Sentry issue is reviewed within 24 hours and either fixed or annotated with context.
  • Defensive programming in the presentation layer: never assume a list is non-empty, never assume a response field is non-null.

Recommended folder structure

lib/
├── core/
│   ├── error/
│   │   ├── failures.dart            # domain Failure hierarchy
│   │   └── exceptions.dart          # data-layer exception types
│   └── crash/
│       └── crash_reporter.dart       # Sentry wrapper, initialized in main()
├── features/
│   └── lesson/
│       ├── data/repositories/
│       │   └── lesson_repository_impl.dart   # catches exceptions → maps to Failures
│       └── presentation/
│           └── cubit/lesson_cubit.dart        # folds Either → emits states
└── main.dart                          # runZonedGuarded + FlutterError.onError

Code example

// core/error/failures.dart — DOMAIN FAILURES
sealed class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  final int? statusCode;
  const ServerFailure([super.message = 'Server error', this.statusCode]);
}

class CacheFailure extends Failure {
  const CacheFailure([super.message = 'Cache error']);
}

class OfflineFailure extends Failure {
  const OfflineFailure() : super('You appear to be offline.');
}

class ValidationFailure extends Failure {
  final Map<String, String> fieldErrors;
  const ValidationFailure(this.fieldErrors) : super('Validation failed');
}

class UnauthorizedFailure extends Failure {
  const UnauthorizedFailure() : super('Your session has expired.');
}

class UnexpectedFailure extends Failure {
  const UnexpectedFailure(String detail) : super('Something went wrong: $detail');
}

// core/error/exceptions.dart — DATA-LAYER EXCEPTIONS (never cross into domain)
class ServerException implements Exception {
  final String message;
  final int? statusCode;
  ServerException(this.message, [this.statusCode]);
}
class CacheException implements Exception {
  final String message;
  CacheException(this.message);
}

// data/repositories/lesson_repository_impl.dart — MAPS exceptions → Failures
class LessonRepositoryImpl implements LessonRepository {
  final LessonRemoteDatasource remote;
  final LessonLocalDatasource local;
  final NetworkInfo networkInfo;

  LessonRepositoryImpl({
    required this.remote,
    required this.local,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
    if (!await networkInfo.isConnected) {
      // Expected: offline — try cache
      return _getFromCacheOrFailure(courseId, const OfflineFailure());
    }
    try {
      final remoteLessons = await remote.fetchLessons(courseId);
      await local.cacheLessons(courseId, remoteLessons);
      return Right(remoteLessons.map((m) => m.toEntity()).toList());
    } on ServerException catch (e) {
      // Server failed — try cache before giving up
      return _getFromCacheOrFailure(
        courseId,
        ServerFailure(e.message, e.statusCode),
      );
    } catch (e, stack) {
      // Truly unexpected — report it, return a safe failure
      await Sentry.captureException(e, stackTrace: stack);
      return Left(UnexpectedFailure(e.toString()));
    }
  }

  Future<Either<Failure, List<Lesson>>> _getFromCacheOrFailure(
    String courseId,
    Failure fallback,
  ) async {
    try {
      final cached = await local.getCachedLessons(courseId);
      if (cached.isEmpty) return Left(fallback);
      return Right(cached.map((m) => m.toEntity()).toList());
    } on CacheException {
      return Left(fallback);
    }
  }
}

// presentation/cubit/lesson_cubit.dart — FOLDS Either → emits states
class LessonCubit extends Cubit<LessonState> {
  final LessonRepository repository;
  LessonCubit(this.repository) : super(LessonInitial());

  Future<void> loadLessons(String courseId) async {
    emit(LessonLoading());
    final result = await repository.getLessons(courseId);
    emit(
      result.fold(
        (failure) => _mapFailureToState(failure),
        (lessons) => LessonLoaded(lessons: lessons),
      ),
    );
  }

  LessonState _mapFailureToState(Failure failure) {
    return switch (failure) {
      OfflineFailure() => LessonError(
          failure.message,
          action: ErrorAction.retry,
        ),
      UnauthorizedFailure() => LessonError(
          failure.message,
          action: ErrorAction.login,
        ),
      ServerFailure(:final statusCode) when statusCode == 404 =>
        LessonError('Lessons not found.', action: ErrorAction.none),
      ServerFailure() => LessonError(
          failure.message,
          action: ErrorAction.retry,
        ),
      _ => LessonError(failure.message),
    };
  }
}

// main.dart — ZONE GUARD + CRASH REPORTING
void main() {
  runZonedGuarded(() {
    WidgetsFlutterBinding.ensureInitialized();
    FlutterError.onError = (details) {
      FlutterError.presentError(details);
      Sentry.captureException(details.exception, stackTrace: details.stack);
    };
    runApp(MyApp());
  }, (error, stack) {
    // Catches uncaught async errors that slip past try/catch
    Sentry.captureException(error, stackTrace: stack);
  });
}

Implementation checklist

  • Define a Failure hierarchy in the domain layer — each Failure is a business-meaningful error state, not an infrastructure exception.
  • Make every repository method return Either<Failure, T> so callers are forced by the type system to handle the error case.
  • Catch infrastructure exceptions (DioError, DriftException) in the repository and map them to domain Failures — never let them cross the boundary.
  • Use a final catch-all in repository methods that reports truly unexpected exceptions to Sentry before returning a generic Failure.
  • Wrap the entire app in runZonedGuarded and set FlutterError.onError to report every uncaught error to Sentry.
  • Track crash-free rate as a first-class metric — aim for 99.9% and triage every Sentry issue within 24 hours.
  • Use pattern matching in state mappers to give users actionable error states (retry, login, contact support) instead of generic messages.

Common mistakes

  • Throwing exceptions across layers instead of returning Either<Failure, T>, making the code path unpredictable and untestable.
  • Catching every exception in a giant try/catch that suppresses bugs instead of reporting them to Sentry.
  • Letting DioError or DriftException leak into the BLoC or presentation layer, coupling it to infrastructure concerns.
  • Not distinguishing recoverable domain failures from unrecoverable programming bugs, causing the app to either crash too much or suppress real issues.
  • Returning generic 'Something went wrong' for every error without mapping specific failures to actionable user messages (retry, login, offline).

Related definitions

Related reading

FAQ

Why Either<Failure, T> instead of try/catch?

Either forces the caller to handle the error case at the type level — the compiler will not let you forget. With try/catch, a caller can forget the catch block and the exception propagates unpredictably. Either makes error handling explicit and part of the function signature, which is especially valuable across module boundaries.

Should I use fpdart or dartz for Either?

Both work. fpdart is more actively maintained and has better null-safety support. dartz is older and has some null-safety quirks. At iStoria we use fpdart. If you do not want a dependency, you can define your own Result<T> sealed class with Success and Failure variants — it is 20 lines of code and gives you the same pattern without a package.

How do I report crashes that Either does not catch?

Wrap your app in runZonedGuarded in main() and set FlutterError.onError. These catch uncaught async errors and Flutter framework errors respectively. Route both to Sentry (or your crash reporter). This covers the 'unexpected' category of errors that should not happen but do — bugs that Either is not designed for.


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work