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?
answer
- every class is an implicit interface
- implementers must add the new member
- exhaustive switches lose a case
- final class grows safely
- dart pub bump breaking
basics
~20 sDart 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 sBecause 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// 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
Recall that every Dart class can be implemented as an interface, so adding a member can break users who implement it.
Explain the two Dart-specific traps - implicit interfaces and exhaustive switches over sealed types and enums - and which class modifiers remove the first.
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.
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