skip to content

Why does Flutter's architecture guide return a sealed Result<T> of Ok or Error from services and repositories instead of throwing exceptions?

level: middleimportance: must knowfreq 45%

answer

  1. Dart exceptions are undeclared
  2. sealed: only Ok or Error
  3. Result.ok and Result.error
  4. switch on Ok<T>() and Error<T>()
  5. catch once at the service edge

basics

~20 s

Dart methods never declare what they throw, so callers forget to catch. A sealed Result<T> puts failure in the return type: the caller must unwrap Ok or Error, typically with a switch, before it can use the value.

solid answer

~40 s

Dart exceptions are unchecked: a method does not declare what it throws and callers are not forced to catch, so a view model can call a repository that calls a service and crash on a network error nobody documented. The guide's alternative is a **sealed** `Result<T>` with two final subclasses: `Ok<T>` holding `value` and `Error<T>` holding an `Exception` in `error`, built with `Result.ok(value)` and `Result.error(e)`. The service wraps its body in `try`/`catch` once and returns `Result.error`; the repository and view model receive `Future<Result<T>>` and must unwrap it, usually with `switch (result) { case Ok<T>(): ...; case Error<T>(): ... }`, which the compiler checks is exhaustive because the class is sealed. The guide calls this a recommendation, not a requirement.

code

dart · 39 lines
dart
import 'dart:io';

import 'result.dart'; // the guide's sealed Result<T>, Ok<T>, Error<T>

class Draft {
  const Draft(this.id, this.body);
  final String id;
  final String body;
}

abstract class DraftApiService {
  Future<int> putDraft(Draft draft); // returns an HTTP status code
}

class DraftRepository {
  DraftRepository({required DraftApiService api}) : _api = api;
  final DraftApiService _api;

  Future<Result<void>> save(Draft draft) async {
    try {
      final status = await _api.putDraft(draft);
      if (status != 200) {
        return Result.error(HttpException('Save failed: $status'));
      }
      return const Result.ok(null);
    } on Exception catch (e) {
      return Result.error(e); // e.g. SocketException when offline
    }
  }
}

String describe(Result<void> result) {
  switch (result) {
    case Ok<void>():
      return 'Draft saved';
    case Error<void>():
      return 'Not saved: ${result.error}';
  }
}

go deeper

for a junior

Know the two cases, Ok with a value and Error with an exception, and that callers switch on them.

for a middle

Explain why Dart's unchecked exceptions motivate the pattern and where the try/catch moves to.

for a senior

Draw the boundary: expected failures become Result at the service edge, programming errors still throw, and commands consume the Result.

for a principal

Decide whether to standardize on the guide's class or a package, and how to migrate an exception-based codebase layer by layer.

## The problem with thrown exceptions across layers In Dart, exceptions are **unchecked**. A method does not declare which exceptions it throws, and callers are not required to catch them. Flutter's architecture guide walks through what that means in a layered app: - a service performs an HTTP call and can throw an `HttpException`, a parsing error or a socket error; - a repository calls the service and passes the result, and any exception, straight through; - a view model calls the repository and must remember to wrap the call in `try`/`catch`. If the view model's author forgets, the code compiles and runs, and fails only when the device goes offline. Documenting the exceptions on the service does not help much, because the view model never calls the service directly. ## The Result type in the guide The guide replaces thrown failures with a return value: | Piece | Role | |---|---| | `sealed class Result<T>` | the return type; can only be one of the two subclasses | | `final class Ok<T>` | success, with the returned `value` | | `final class Error<T>` | failure, with an `Exception` in `error` | | `Result.ok(value)` / `Result.error(e)` | const factory constructors that redirect to the subclasses | Because the class is `sealed`, a `switch` over a `Result<T>` with a case for `Ok<T>()` and a case for `Error<T>()` is exhaustive, so the compiler rejects a switch that forgets one of them. ## Where the conversion happens In a blogging app that saves drafts: 1. `DraftApiService.save` wraps its call in `try` and `on Exception catch (e)` returns `Result.error(e)`; a non-success status becomes `Result.error(HttpException(...))`. 2. `DraftRepository.save` returns `Future<Result<void>>`, passing the service's result through or adding its own fallback. 3. `EditorViewModel` switches on the result: on `Ok` it updates the saved time, on `Error` it stores the failure for the UI. The guide also shows that `Result` simplifies fallback logic: instead of nested `try`/`catch`, a repository checks `if (apiResult is Ok) return apiResult;` and only then tries the local database. ## Limits worth stating - `Error<T>` holds an **`Exception`**. Dart `Error`s, such as a failed assertion or a null check on a null value, are programming bugs and still throw; the pattern is for expected failures. - Code inside the service can still throw if its `try` misses a path; the pattern moves the catch to one place, it does not make catching unnecessary there. - The guide calls `Result` a recommendation, not a requirement, and points to ready-made packages on pub.dev as alternatives. - Wrapping every call adds some ceremony; teams usually apply it at service and repository boundaries, not inside pure functions. ## How it connects to commands The guide's full `Command0` and `Command1` require their actions to return `Future<Result<T>>`. The command reads `result is Error` for its `error` flag and `result is Ok` for `completed`, so the UI layer never needs a `try`/`catch` for expected failures.

  • Why is Result declared sealed rather than abstract?
    A sealed class can only be subclassed in its own library, so the compiler knows `Ok` and `Error` are the only cases. That lets a `switch` over `Result<T>` be checked for exhaustiveness; with an abstract class, a missing case would not be reported.
  • Should a StateError from a bug be wrapped in Result.error?
    No. The guide's `Error<T>` holds an `Exception`, and Dart `Error` types signal programming mistakes that should fail loudly in development. Result is for expected, recoverable failures such as being offline or a rejected request.

saying these in an interview costs you the question

  • Dart forces callers to catch the exceptions a method declares.
  • Result.error should also wrap assertion failures and null-check errors.
  • With Result, services no longer need any try/catch at all.
  • A switch on Result needs a default branch to compile.
  • The guide makes Result mandatory for its architecture.