skip to content

In a published Dart 3 package, why is adding a method to a plain public class, or a subtype to a sealed class, a breaking change?

level: seniorimportance: must knowfreq 40%

answer

  1. every class is an implicit interface
  2. implementers must add the new member
  3. exhaustive switches lose a case
  4. final class grows safely
  5. dart pub bump breaking

basics

~20 s

Dart classes are implicit interfaces, so a new member breaks users who implements the class, and a new sealed subtype or enum value breaks their exhaustive switches. Marking a class final stops outside subtyping, so its members can grow in minor releases.

solid answer

~50 s

Because every Dart class also declares an interface, a user can write `class MyRgb implements Rgb`; the day `Rgb` gains a member, that class no longer compiles. The same reasoning makes a new optional parameter on an overridable method breaking, since overrides must accept it, and a new required parameter breaking for every caller. Pattern matching adds a second trap: a `switch` over a `sealed` class or an enum is checked for exhaustiveness, so a new subtype or enum value turns users' switches without a wildcard case into compile errors. Dart 3's class modifiers let the author choose the promise: a `final` class can't be extended or implemented outside its library, so its members can grow freely, while `base` and `interface` each close one door. Adding a modifier to an existing class is itself breaking. Release it with `dart pub bump breaking` and a migration note in `CHANGELOG.md`.

code

dart · 12 lines
dart
// colour_tools 1.x: announce the change before making it.
@Deprecated.implement('Rgb becomes a final class in 2.0; wrap it instead.')
class Rgb {
  const Rgb(this.red, this.green, this.blue);

  final int red;
  final int green;
  final int blue;
}

// colour_tools 2.0.0 then declares `final class Rgb { ... }`,
// after which members can be added in minor releases.

go deeper

for a junior

Recall that every Dart class can be implemented as an interface, so adding a member can break users who implement it.

for a middle

Explain the two Dart-specific traps - implicit interfaces and exhaustive switches over sealed types and enums - and which class modifiers remove the first.

for a senior

Classify real changes as breaking or not, plan class modifiers before 1.0, deprecate with the Dart 3.10 annotations, and ship with dart pub bump breaking and a migration note.

for a principal

Decide what API surface to promise: closed final classes evolve freely, open classes and sealed hierarchies trade flexibility for more frequent major versions.

## Why "nothing was removed" can still break users A **breaking change** is one that can make a consumer's code stop compiling or behave differently after an upgrade. Removing or renaming public API is the obvious case. Dart adds cases that come from two language features: **implicit interfaces** and **exhaustiveness checking**. **Implicit interfaces.** In Dart there is no separate interface declaration: every class also defines an interface made of its public members, and any other class may `implements` it. So a consumer of an open-source `colour_tools` package can write a test double or an adapter: ```dart import 'package:colour_tools/colour_tools.dart'; class FakeRgb implements Rgb { // ...every member of Rgb, written by hand } ``` If a minor release adds `Rgb.toHsl()`, `FakeRgb` no longer implements the whole interface and fails to compile - although nothing was removed. **Exhaustiveness.** In Dart 3, a `switch` over a value whose type is an enum or a `sealed` class is checked at compile time: if some possible value matches no case and there is no `default` or `_` case, compilation fails. Adding a subtype to a sealed class or a value to an enum therefore breaks every consumer switch that listed the old cases exhaustively. dart.dev calls this "exactly like adding a new value to an enum". ## Common changes and their verdict | Change to a public API | Who breaks | Verdict | |---|---|---| | Add a top-level function or a new class | nobody | minor | | Add a member to a plain (unmodified) class | code that `implements` it | breaking | | Add a member to a `final` class | nobody outside the library can subtype it | minor | | Add an optional parameter to a top-level function | nobody | minor | | Add an optional parameter to an overridable method | subclasses and implementers whose overrides lack it | breaking | | Add a required parameter anywhere | every caller | breaking | | Add a subtype to a `sealed` class, or a value to an enum | exhaustive switches with no wildcard case | breaking | | Add `final`, `base`, `interface` or `sealed` to an existing class | code that extended or implemented it | breaking | ## Class modifiers as an API promise Dart 3's class modifiers restrict what code **outside the declaring library** may do with a class: | Declaration | Extend outside? | Implement outside? | |---|---|---| | `class` | yes | yes | | `base class` | yes | no | | `interface class` | no | yes | | `final class` | no | no | | `sealed class` | no | no (and it is exhaustive) | For a package author the value is freedom to evolve. dart.dev's guide for API maintainers notes that with a `final` class you can add new methods or turn constructors into factories without breaking downstream users. The flip side: **adding** a modifier to a class that is already public is a breaking change, because it forbids something users may already do. The details of each modifier belong to the language; what matters here is choosing them **before** 1.0, or at a major version. ## Shipping the break 1. **Announce it in the current major.** Mark what will go with `@Deprecated('...')`. Since Dart 3.10, `@Deprecated.implement()`, `@Deprecated.extend()` and `@Deprecated.subclass()` warn users who implement or extend a class you plan to make `final`. 2. **Bump the version** with `dart pub bump breaking`, which picks the breaking increment for the current version: the next major from 1.0.0 upwards, the next minor below it, following Dart's convention that 0.x versions shift each meaning down one slot. `dart pub bump` then reminds you to update `CHANGELOG.md`. 3. **Write the migration** in `CHANGELOG.md`: a *Breaking change* entry per item and an "Upgrading from 1.x" section. 4. **Optionally publish a prerelease** such as `2.0.0-dev.1` so early adopters can test; it doesn't replace the stable version's page. 5. **Dry-run and publish.** ## Summary - Implicit interfaces make additive changes breaking for implementers. - Exhaustive switches make new sealed subtypes and enum values breaking. - `final` (and to a degree `base`) classes can grow without breaking anyone; adding a modifier later is itself breaking. - Ship breaks as a major version with deprecations first and a migration note.

  • Is adding an optional named parameter to a method always a minor change in Dart?
    No. For a top-level function or a method nobody can override, it is additive. But if the method is on a class users may extend or implement, their overrides lack the new parameter and no longer compile, so it is breaking. Making the class `final` removes that risk.
  • How do you tell users who implement `Rgb` that it will become `final`?
    In the current major, annotate the class with `@Deprecated.implement('...')` (Dart 3.10+), which warns only at `implements` sites, and describe the plan in `CHANGELOG.md`. Then add `final` in the next major version, bumped with `dart pub bump breaking`, with a migration section explaining the alternative.

saying these in an interview costs you the question

  • Adding methods can never break users because nothing was removed
  • Adding a subtype to a sealed class is minor since old cases still match
  • Adding final to an existing public class is safe in a patch release
  • Only changes to top-level functions count; class changes are internal
  • A new optional parameter is additive even on methods users override