skip to content

In Dart, when should a payment-method hierarchy be `sealed` rather than `final`, and what does each cost when you later add a method?

level: middleimportance: should knowfreq 45%

answer

  1. construct the supertype directly?
  2. subtypes in the same library
  3. exhaustive switches without default
  4. new subtype breaks sealed users
  5. final forces a default case

basics

~20 s

Use sealed when PaymentMethod is abstract with a fixed set of same-library subtypes and callers should switch over them exhaustively; adding a subtype is then breaking. Use final when the type must be constructible or new subtypes must be non-breaking.

solid answer

~40 s

Both stop other libraries from extending or implementing `PaymentMethod`. `sealed` additionally makes it implicitly abstract and lets the compiler treat its same-library direct subtypes — `Card`, `Wallet`, `BankTransfer` — as the complete set, so user switches with a case per subtype need no default. The cost: adding `Voucher` in v2 makes every such switch non-exhaustive, a breaking change that needs a major version, exactly like adding an enum value. `final` gives no exhaustiveness, so users must write a default case; in return you can add subtypes without breaking them, and the class itself can be constructed. dart.dev's rules: if users must construct instances of the class itself, it cannot be `sealed`; if it has no subtypes, `sealed` buys nothing.

code

dart · 30 lines
dart
// payments.dart
sealed class PaymentMethod {
  const PaymentMethod();
  factory PaymentMethod.card(String last4) = Card;
}

final class Card extends PaymentMethod {
  const Card(this.last4);
  final String last4;
}

final class Wallet extends PaymentMethod {
  const Wallet(this.provider);
  final String provider;
}

final class BankTransfer extends PaymentMethod {
  const BankTransfer(this.iban);
  final String iban;
}

// app.dart
// PaymentMethod();                      // error: sealed is implicitly abstract
// class Voucher extends PaymentMethod {} // error: direct subtypes must live in payments.dart

String label(PaymentMethod m) => switch (m) {
      Card(:final last4) => 'Card ending $last4',
      Wallet(:final provider) => provider,
      BankTransfer() => 'Bank transfer',
    }; // exhaustive: no default needed

go deeper

for a junior

Recall that sealed is abstract with same-library subtypes and enables exhaustive switches, while final can be constructed and gives no exhaustiveness.

for a middle

Explain the evolution trade-off: a new sealed subtype breaks exhaustive user switches, while final forces a default case but lets you add subtypes freely.

for a senior

Choose per exported hierarchy based on how often variants will be added, and plan major releases and changelog notes around new sealed subtypes.

for a principal

Weigh compiler-enforced completeness for users against the release cadence and compatibility promises the package makes to downstream teams.

## Two ways to close a hierarchy Suppose a payments package exports a `PaymentMethod` type with three variants: `Card`, `Wallet` and `BankTransfer`. The package does not want other packages inventing new payment methods, because the charging code only knows these three. Both `sealed` and `final` stop other libraries from extending or implementing `PaymentMethod`. They differ in what they promise to *users* and what they cost the *maintainer*. ## What `sealed` promises ```dart sealed class PaymentMethod {} final class Card extends PaymentMethod { Card(this.last4); final String last4; } final class Wallet extends PaymentMethod { Wallet(this.provider); final String provider; } final class BankTransfer extends PaymentMethod { BankTransfer(this.iban); final String iban; } ``` - `PaymentMethod` is **implicitly abstract**: nobody can create a bare `PaymentMethod()`. - Every **direct subtype must be in the same library** (the same file or one of its `part` files). - Because the set of direct subtypes is known, the compiler accepts a switch with one case per subtype as **exhaustive**, with no default branch. How that checking works belongs to the pattern-matching topic. dart.dev compares this to an enum: the subtypes behave like a fixed list of values that user code can rely on covering completely. ## What `final` promises ```dart final class PaymentMethod { PaymentMethod(this.label); final String label; } ``` - Other libraries can **construct** it but cannot extend, implement, mix it in or use it in an `on` clause. - There is **no exhaustiveness**: even if user code tests for every known subtype, the compiler requires a default case. - Subclasses in your own library must be `base`, `final` or `sealed` (the restriction is transitive). ## The cost of adding a variant later | Change in v2 | `sealed` hierarchy | `final` hierarchy | |---|---|---| | Add `Voucher` subtype | **breaking**: every exhaustive user switch stops compiling | non-breaking: user default cases handle it | | Add a method to the supertype | non-breaking for users (they cannot subtype it) | non-breaking for users | | Users create instances of the supertype itself | impossible | allowed | | Users get compile errors pointing at unhandled variants | yes | no | The dart.dev maintainer guide frames the breaking change positively: those errors are useful, because they point users at every place that must now handle `Voucher`. But it is still a breaking change, and a package that expects to add variants often should prefer `final`. ## A decision checklist 1. **Must users create instances of the supertype itself?** Then it cannot be `sealed`, since sealed types are implicitly abstract. 2. **Does the type have subtypes in your library?** If not, `sealed` gives no benefit. 3. **Do users need to handle each variant separately and be told when a new one appears?** Choose `sealed`, and treat new variants as major releases. 4. **Do you want to add variants freely in minor releases?** Choose `final` for the supertype, and accept that users write default cases. ## Details interviewers probe - `abstract sealed class` is a compile-time error: sealed is already abstract. - A sealed class **can declare constructors** for its subclasses to call and **factory constructors** that return a subtype, e.g. `factory PaymentMethod.card(String last4) = Card;`. - `sealed` is **not transitive**: if `Card` has no modifier, another library may extend `Card`. That does not break exhaustiveness over `PaymentMethod`, because such a subclass is still a `Card`. Mark the variants `final` if you want them closed too. - The SDK uses the same trade-off: `num` is `sealed` in `dart:core`, with `int` and `double` as its direct subtypes, and `dart:io`'s `FileSystemEvent` became `sealed` in Dart 3.1 — itself listed as a breaking change.

  • In Dart, can a sealed class declare constructors if it cannot be instantiated?
    Yes. It can declare generative constructors that its same-library subclasses call through `super`, and factory constructors that return a subtype, such as `factory PaymentMethod.card(String last4) = Card;`. What it cannot do is produce a bare instance of itself.
  • In Dart, if Card is an unmodified subclass of sealed PaymentMethod, can another library extend Card?
    Yes. `sealed` is not transitive, so `Card` is an ordinary open class. Exhaustiveness over `PaymentMethod` still holds, because any subclass of `Card` is a `Card` and matches the `Card` case. Mark `Card` `final` if it should be closed too.

A sealed hierarchy is a printed menu with a fixed list of dishes: diners can plan around every item, but adding a dish means reprinting every menu. A final type is a kitchen that takes orders it may not recognise and always keeps a chef's-choice fallback.

saying these in an interview costs you the question

  • Adding a subtype to a sealed class is a non-breaking change.
  • A final supertype gives exhaustive switches just like sealed.
  • Sealed subclasses may live anywhere in the same package.
  • Every subclass of a sealed class is automatically sealed too.
  • A sealed class cannot declare any constructors.