Sparround

The Result class: a sealed class and an exhaustive switch

The official Flutter docs give Result a concrete shape, built on Dart 3's sealed classes:

  • sealed class Result<T> — the base; because it is sealed, all of its subtypes must live in the same library and the compiler knows them all.
  • Ok<T> — the success case, carrying a value.
  • Error<T> — the failure case, carrying an Exception.
  • Two const factory constructors: Result.ok(value) and Result.error(exception).

The main thing sealed gives you: when a switch does not cover every subtype, the compiler tells you. So "I forgot to write the error case" is caught at compile time rather than in production.

Ok and Error are declared final — they have no subtypes either, so two cases are always enough for a switch.

dart
// fayl: lib/utils/result.dart

/// Uğur (`Ok`) və uğursuzluq (`Error`) hallarını bir tipdə birləşdirir.
sealed class Result<T> {
  const Result();

  /// Uğurlu nəticə: [value] daşıyır.
  const factory Result.ok(T value) = Ok._;

  /// Uğursuz nəticə: [error] daşıyır.
  const factory Result.error(Exception error) = Error._;
}

/// Uğurlu nəticə.
final class Ok<T> extends Result<T> {
  const Ok._(this.value);
  final T value;
}

/// Uğursuz nəticə.
final class Error<T> extends Result<T> {
  const Error._(this.error);
  final Exception error;
}

// ══ İstifadə: switch ifadəsi ══
final Result<UserProfile> result = await repository.fetchProfile();

switch (result) {
  case Ok<UserProfile>():
    userProfile = result.value;
  case Error<UserProfile>():
    error = result.error;
}

// ══ Daha yığcam: pattern ilə dəyəri birbaşa çıxarmaq ══
final message = switch (result) {
  Ok(:final value) => 'Salam, ${value.name}',
  Error(:final error) => 'Xəta: $error',
};

// ══ Yalnız bir hal lazımdırsa: if-case ══
if (result case Ok(:final value)) {
  print(value.name);
}

// ══ Kompilyator nə tutur ══
// Aşağıdaki switch `Error` halını əhatə etmir:
//   final x = switch (result) {
//     Ok(:final value) => value.name,
//   };
// → "The type 'Result<UserProfile>' is not exhaustively matched"
// Yəni xəta halını unutmaq KOMPİLYASİYA xətasıdır.

The `Result` class as given by the official docs, and its use with `switch`.

A naming trap. In the official sample the failure class is called Error — and that name collides with Dart's own dart:core Error. Inside that file, writing Error now means Result's Error; reaching dart:core's Error requires a prefixed import.

This is not a compile error, but it is a real source of confusion — especially in code like catch (e) { if (e is Error) … }.

Three practical options:

  • Keep the official names (Ok / Error) and keep that file small and clean.
  • Ok / Err — short and free of collisions.
  • Success<T> / FailureResult<T> — longer, but unambiguous when reading.

Whichever you pick, use one set of names project-wide; a codebase with two different Result types is the worst case.

AdditionBenefitCaution
`bool get isOk`Reads well in simple conditionsDoes not extract the value — you still need a `switch`
`T? get valueOrNull`Handy for fallbacks: `valueOrNull ?? []`Silently swallows the error — only when the error truly does not matter
`Result<R> map<R>(R Function(T) f)`Transforms the success value and passes the error throughIf `f` throws, it is not wrapped — you must catch it yourself
`R fold<R>(R Function(T) onOk, R Function(Exception) onError)`Handles both cases in one expressionA `switch` often reads better — `fold` needs two lambdas
`Result<void>`For operations with no return value (delete, submit)You write `Result<void>.ok(null)`, not `Ok(null)` — the syntax can confuse

How a `Result` travels between layers. In practice:

1. The repository catches exceptions and returns a Result: Future<Result<List<Order>>>. 2. A use case (if present) passes it through or combines several. 3. The notifier opens it with a switch and turns it into state (or into AsyncValue). 4. The widget never sees a Result — only state.

That last point matters: a Result should not reach the UI. Writing switch (result) in a widget mixes two layers — the widget starts making decisions about error types.

Together with Riverpod. AsyncValue is sealed too (Riverpod 3), so the two mechanisms can end up duplicating each other. The practical split:

  • Reads: no need for ResultAsyncNotifier.build converts an exception into AsyncValue.error.
  • Writes / commands: Result helps — the notifier's method returns it, and the view shows a snackbar or navigates accordingly.

Practice. Create lib/utils/result.dart in the shape above (pick your own name set, but pick it once). Then move one write operation (submit, delete, redeem) onto Result: the repository returns a Result, the notifier's method passes it to the view, and the view shows a different snackbar for success and failure.

Done means: no try/catch in the notifier, no Result type visible in the widget, and deleting one case from the switch produces a compile error (try it once and revert — seeing the mechanism work is worth it).

📚 Sources and documentation