In Dart, when should a payment-method hierarchy be `sealed` rather than `final`, and what does each cost when you later add a method?
answer
- construct the supertype directly?
- subtypes in the same library
- exhaustive switches without default
- new subtype breaks sealed users
- final forces a default case
basics
~20 sUse 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 sBoth 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// 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 neededgo deeper
Recall that sealed is abstract with same-library subtypes and enables exhaustive switches, while final can be constructed and gives no exhaustiveness.
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.
Choose per exported hierarchy based on how often variants will be added, and plan major releases and changelog notes around new sealed subtypes.
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.