skip to content

In Dart 3, if `PaymentMethod` is a `base` class, why must every subclass be `base`, `final` or `sealed`, and does the same apply under `interface` or `sealed`?

level: seniorimportance: should knowfreq 30%

answer

  1. base is contagious
  2. direct or indirect subtypes
  3. final encompasses base
  4. sealed and interface don't propagate
  5. implicit_reopen and @reopen

basics

~20 s

base guarantees every instance inherits the real implementation, so a subclass that allowed implements would reopen the hole; hence subtypes of base or final must be base, final or sealed. interface and sealed do not propagate.

solid answer

~40 s

`base` promises that every object of type `PaymentMethod` really inherits its code, including private members and its constructor. If `class GiftCard extends PaymentMethod` had no modifier, another library could `implements GiftCard` and obtain a `PaymentMethod` with none of that code. So Dart requires every direct or indirect subtype of a `base` or `final` type to be `base`, `final` or `sealed`, even in the declaring library, and reports an error otherwise. `final` includes `base`'s effect, so the same rule applies. `sealed` and `interface` are not transitive: a plain subclass of either is fully open. The experimental `implicit_reopen` lint flags that silent reopening and asks for `@reopen` from `package:meta` when it is intended.

code

dart · 15 lines
dart
// payments.dart
base class PaymentMethod {
  void _audit() {}
  void charge(int cents) => _audit();
}

// app.dart
import 'payments.dart';

// class GiftCard extends PaymentMethod {}   // error: must be 'base', 'final' or 'sealed'
base class GiftCard extends PaymentMethod {}   // OK
final class Coupon extends GiftCard {}         // OK: indirect subtype also restricted
// base class Fake implements PaymentMethod {} // error: base can't be implemented outside

sealed class Loyalty extends PaymentMethod {}  // OK: sealed satisfies the rule

go deeper

for a junior

Remember that subclasses of base or final classes must themselves be base, final or sealed.

for a middle

Explain why the rule exists: an open subclass would let another library implement it and bypass the base guarantees.

for a senior

Anticipate the ripple on users when adding base to a published type, and use implicit_reopen and @reopen to keep interface and sealed hierarchies closed on purpose.

for a principal

Decide which guarantees an exported hierarchy must keep across every downstream subclass, and whether that constraint on users' own designs is worth it.

## What `base` actually guarantees A `base` class in Dart forbids other libraries from **implementing** it. The point of that restriction is a guarantee about **every instance** of the type: - the class's **constructor has run** for it; - its **private members exist** and are the real implementation; - a new concrete member added later is **inherited** by all subtypes. That guarantee only holds if no path exists to create a `PaymentMethod` without inheriting from it. ## Why the restriction must travel down the hierarchy Consider: ```dart // payments.dart base class PaymentMethod { void _audit() {} void charge(int cents) => _audit(); } // app.dart base class GiftCard extends PaymentMethod {} // must be base, final or sealed ``` If `GiftCard` were allowed to be a plain `class`, a third library could write `class Fake implements GiftCard`. `Fake` is a subtype of `GiftCard`, therefore of `PaymentMethod`, yet it inherits nothing — `_audit` would be missing at runtime. The hole reopens one level down. Dart closes it by making `base` **transitive** ("contagious", in the dart.dev maintainer guide): every **direct or indirect** subtype of a `base` or `final` type must itself be `base`, `final` or `sealed`. The analyzer's diagnostic reads: *"The type 'GiftCard' must be 'base', 'final' or 'sealed' because the supertype 'PaymentMethod' is 'base'."* For mixins: *"The mixin 'M' must be 'base' because the supertype 'PaymentMethod' is 'base'."* Key points: 1. The rule applies **even inside the declaring library**. Other restrictions are relaxed there; this one is not. 2. **`final` encompasses `base`**: subtypes of a `final` class, which can only exist in the same library, must also be `base`, `final` or `sealed`. 3. A **`sealed`** subtype satisfies the rule because it too cannot be implemented from outside, and its own subtypes then fall under the rule again. ## Modifiers that do not propagate | Supertype modifier | Must subtypes carry a modifier? | What an unmodified subtype allows | |---|---|---| | `base` | yes: `base`, `final` or `sealed` | — (not allowed) | | `final` | yes: `base`, `final` or `sealed` | — (not allowed) | | `interface` | no | the subtype can be extended and implemented anywhere | | `sealed` | no | the subtype can be extended and implemented anywhere | `interface` protects the interface class's own self-calls; a subclass that decides to allow extension is making its own promise. `sealed` guarantees a closed set of **direct** subtypes; a further subclass of `Card` is still a `Card`, so exhaustiveness is unaffected. The maintainer guide spells it out for `sealed`: unlike `base` and `final`, there is no transitive restriction. ## Guarding against accidental reopening Because `interface` and `sealed` do not propagate, a same-library subclass without a modifier silently **reopens** what the author may have meant to keep closed: ```dart interface class PaymentGateway {} class StripeLikeGateway extends PaymentGateway {} // reopened: extendable anywhere ``` The linter rule **`implicit_reopen`** (marked experimental in the dart.dev rule list) flags this and asks you either to add a modifier (`final class ...`) or to annotate the subclass with **`@reopen`** from `package:meta` to show it is deliberate. ## Consequences for a package author - Putting `base` on a published class forces **every user subclass** to change its declaration: a breaking change needing a major version. - `base` also constrains **your users' users**: a user's `base class GiftCard` cannot offer an implementable interface to anyone else. - dart.dev notes this is how most languages without implicit interfaces behave anyway: in them, every class is effectively non-implementable. - If you need test doubles for a `base` type, they must **extend** it, which means running its constructor — something to design for. ## A worked puzzle Given `base class PaymentMethod` in `payments.dart`, which of these compile in `app.dart`? - `class GiftCard extends PaymentMethod {}` — **error**: must be `base`, `final` or `sealed`. - `final class GiftCard extends PaymentMethod {}` — **OK**. - `sealed class Loyalty extends PaymentMethod {}` — **OK**. - `base class Fake implements PaymentMethod {}` — **error**: `base` cannot be implemented outside its library, whatever the implementer's modifier.

  • In Dart 3, does the base transitivity rule apply to subclasses in the same library as the base class?
    Yes. Other modifier restrictions are relaxed inside the declaring library, but every subtype of a `base` or `final` type must be `base`, `final` or `sealed` wherever it is declared. Only the ban on implementing is lifted locally, so a same-library class may implement a `base` class if it is itself `base`, `final` or `sealed`.
  • In Dart 3, what do the implicit_reopen lint and @reopen annotation do?
    `implicit_reopen`, an experimental linter rule, flags a subclass without a modifier that silently relaxes a supertype's `interface`, `base`, `final` or `sealed` controls. You fix it by adding a modifier, or mark the reopening as intended with `@reopen` from `package:meta`.

saying these in an interview costs you the question

  • Subclasses of a base class can be plain classes inside the same library.
  • Subclasses of a sealed class are automatically sealed.
  • base transitivity only applies to direct subclasses, not indirect ones.
  • Marking the implementer base makes implementing a base class legal elsewhere.
  • interface is transitive just like base.