skip to content

In Dart 3, how would you model a PaymentResult as a sealed class hierarchy and handle every outcome with an exhaustive switch?

level: seniorimportance: must knowfreq 55%

answer

  1. one supertype, one subtype per outcome
  2. sealed is implicitly abstract
  3. all direct subtypes in one library
  4. object patterns destructure each variant
  5. no _ so new outcomes break the build

basics

~20 s

Declare sealed class PaymentResult with one subclass per outcome in the same library, each holding its own data, then switch over it with one object pattern per subtype and no wildcard. The compiler proves coverage and flags every switch when an outcome is added.

solid answer

~50 s

I declare `sealed class PaymentResult` and one subtype per outcome — `PaymentSucceeded` with a receipt id and amount, `PaymentDeclined` with a reason, `PaymentNeedsVerification` with a URL — all in the same library, usually `final` so each variant stays closed. `sealed` makes the supertype implicitly abstract and forbids subtypes outside the library, so the compiler knows the complete list. Consumers use a switch expression with one object pattern per subtype, such as `PaymentDeclined(:var reason) => ...`, which checks the type and destructures typed fields at once; with every subtype covered and no `_`, it is exhaustive. The payoff: adding `PaymentCancelled` turns every such switch into a `non_exhaustive_switch_expression` error at exactly the places that must decide what to do. A trailing `_`, a guard on a subtype's only case, or a nullable scrutinee without a `null` case weakens that guarantee.

code

dart · 34 lines
dart
import 'package:flutter/widgets.dart';

sealed class PaymentResult {
  const PaymentResult();
}

final class PaymentSucceeded extends PaymentResult {
  const PaymentSucceeded({required this.receiptId, required this.amountCents});
  final String receiptId;
  final int amountCents;
}

final class PaymentDeclined extends PaymentResult {
  const PaymentDeclined(this.reason);
  final String reason;
}

final class PaymentNeedsVerification extends PaymentResult {
  const PaymentNeedsVerification(this.verificationUrl);
  final Uri verificationUrl;
}

class PaymentStatus extends StatelessWidget {
  const PaymentStatus({super.key, required this.result});

  final PaymentResult result;

  @override
  Widget build(BuildContext context) => switch (result) {
    PaymentSucceeded(:var receiptId) => Text('Paid. Receipt $receiptId'),
    PaymentDeclined(:var reason) => Text('Declined: $reason'),
    PaymentNeedsVerification() => const Text('Confirm in your bank app'),
  };
}

go deeper

for a junior

Recognise the pattern: a sealed supertype, one subclass per outcome, and a switch expression with one case per subclass.

for a middle

Explain why sealed enables the check: implicitly abstract, subtypes confined to one library, and object patterns that destructure typed fields.

for a senior

Show the maintenance payoff and its traps: new subtypes breaking every switch on purpose, and wildcards, guards or nullable scrutinees quietly defeating it.

for a principal

Decide where sum types belong in the architecture and when adding a variant to a published sealed type should be treated as a breaking change.

## The problem a sealed hierarchy solves A payment attempt ends in one of a few **mutually exclusive outcomes**, and each carries different data: a success has a receipt id and an amount, a decline has a reason, an extra-verification step has a URL to open. A single class with nullable fields for all of them lets impossible combinations exist (a receipt *and* a decline reason) and leaves every caller guessing which fields are set. An `enum` cannot help either: its values are a fixed set of constants declared up front, so they cannot carry per-instance runtime data such as this payment's receipt id. Dart 3's answer is a **sealed class hierarchy**: a `sealed` supertype whose direct subtypes are all declared in the **same library**. Because the compiler can list every direct subtype, a `switch` over the supertype can be checked for **exhaustiveness** — this is how Dart expresses a *sum type* (an algebraic data type) on top of ordinary classes. ## Modelling PaymentResult ```dart sealed class PaymentResult { const PaymentResult(); } final class PaymentSucceeded extends PaymentResult { const PaymentSucceeded({required this.receiptId, required this.amountCents}); final String receiptId; final int amountCents; } final class PaymentDeclined extends PaymentResult { const PaymentDeclined(this.reason); final String reason; } final class PaymentNeedsVerification extends PaymentResult { const PaymentNeedsVerification(this.verificationUrl); final Uri verificationUrl; } ``` Key facts about this declaration: - `sealed` makes `PaymentResult` **implicitly abstract**: `PaymentResult()` cannot be constructed, so every instance is one of the subtypes. - `sealed` forbids extending or implementing `PaymentResult` **outside its library**, which is what lets the compiler know the list is complete. - The restriction is **not transitive**: the subtypes can still be extended elsewhere unless you restrict them too. Marking them `final` keeps each variant closed. - A sealed class can still declare a constructor for its subclasses to call, as the `const PaymentResult()` above does. ## Handling it exhaustively ```dart String describe(PaymentResult result) => switch (result) { PaymentSucceeded(:var receiptId, :var amountCents) => 'Paid ${amountCents / 100} (receipt $receiptId)', PaymentDeclined(:var reason) => 'Declined: $reason', PaymentNeedsVerification() => 'Confirm the payment in your bank app', }; ``` Each case is an **object pattern**: it tests the subtype and destructures its getters in one step, and inside the case the fields are typed correctly without casts. Because there is one unguarded case per direct subtype and no `_`, the switch is exhaustive and **needs no default**. In a Flutter widget the same switch expression can be returned from `build` or passed as a `child`, so the UI for each outcome sits in one place. ## What the compiler buys you 1. **A new outcome is a compile error at every switch.** Add `PaymentCancelled` to the library and every exhaustive switch over `PaymentResult` without a wildcard now fails with `non_exhaustive_switch_expression` (or `non_exhaustive_switch_statement`), naming `PaymentCancelled()`. The error lands in the code that must decide what to show. 2. **No unreachable default to test.** There is no "unknown result" branch to invent behaviour for. 3. **Type-safe data access.** You cannot read `receiptId` from a decline. ## Choices that change the guarantee | Choice | Effect | |---|---| | a trailing `_ =>` case | compiles today, silently absorbs the next subtype | | a guard on the only case for a subtype | that subtype is no longer covered | | scrutinee typed `PaymentResult?` | needs a `null` case too | | supertype declared `abstract` or `final` instead of `sealed` | no exhaustiveness; a `_` is required | | subtypes spread across libraries | not allowed — the compiler rejects them outside the sealed type's library | ## Design judgement - **Sealed vs enum.** Use an enum when outcomes are fixed constants without per-instance data; use a sealed hierarchy when each outcome carries its own fields. - **Switch vs methods.** A `describe` method on each subclass also works, but it spreads presentation across model classes. A switch keeps one operation's variants together and lets the model stay free of UI concerns — the ADT style the Dart docs recommend when behaviour varies per subtype but belongs in one place. - **Nesting.** A direct subtype can itself be `sealed` (for example, a sealed `PaymentDeclined` with card and bank variants); a switch then covers it either with `PaymentDeclined()` or with every one of its own subtypes. - **Public APIs.** In a package, adding a subtype to a published sealed type breaks every client's exhaustive switch, so it is a breaking change; choose `sealed` when clients should handle every case, and a non-sealed type when you want room to add outcomes.

  • What happens to that Dart switch if a teammate adds PaymentCancelled to the sealed hierarchy?
    Every switch over `PaymentResult` without a wildcard stops compiling with a non-exhaustive error naming `PaymentCancelled()`, so each screen or handler has to decide how to present a cancellation. A switch that ended in `_` keeps compiling and silently shows whatever the wildcard returns.
  • When would you choose a Dart enum instead of a sealed class for payment outcomes?
    When the outcomes are fixed constants that carry no per-instance data, such as a status code. Enums are also exhaustively checked. Once one outcome needs its own fields, like a receipt id or a verification URL, a sealed hierarchy fits, because each subtype declares exactly the data that outcome has.
  • How does a Dart switch cover a direct subtype of PaymentResult that is itself sealed?
    Either with one unconstrained case for that subtype, such as `PaymentDeclined()`, or with a case for each of its own subtypes. The checker expands a sealed subtype into its subtypes, so both forms prove coverage.

A sealed PaymentResult is like a paper form with a fixed set of tick boxes printed by one office. Because nobody else can print a new box, a clerk's checklist that handles every printed box is provably complete; add a box at the office and every checklist without an 'anything else' line is visibly out of date.

saying these in an interview costs you the question

  • A trailing _ case keeps a sealed switch safe against new subtypes.
  • Sealed subtypes can be declared in any library of the package.
  • An enum value can carry each payment's own receipt id.
  • A sealed class can be instantiated directly like an abstract base.
  • Sealing PaymentResult also stops other libraries from extending its subtypes.