dart-seal-type-hierarchies · git:20260913.6c39005 · 2026-09-13 · sha256 608af5ced2b2353f
dart-seal-type-hierarchies git:20260913.6c39005A
Immutable. This exact content is served forever at /api/v1/blob/608af5ced2b2353f.
---
name: dart-seal-type-hierarchies
description: |-
Identify closed type hierarchies that are not declared `sealed`, and seal
them so the compiler can enforce switch exhaustiveness. Covers the same-library
requirement, the public-API breaking-change tradeoff, and the migration from
`is` cascades to exhaustive switches.
key_features:
- Closed hierarchy detection
- Exhaustiveness enforcement
- Public API breaking-change analysis
---
# Seal Type Hierarchies
## 1. When to use this skill
Use this skill when:
- A package declares an abstract base type whose subtypes are all defined in the
same library, but the base type is not marked `sealed`.
- Code branches over the members of such a hierarchy with `is` checks or a
non-exhaustive `switch`, so adding a subtype later fails silently at runtime
instead of loudly at compile time.
- Reviewing a new type hierarchy and deciding whether it should be open to
external subtyping.
### When NOT to use (Abstention Guardrails)
Do NOT seal a type when:
- **Subtypes span multiple libraries**: `sealed` requires every direct subtype
to be declared in the same library as the base type. If subtypes live in other
files (and are not `part of` the same library), the code will not compile.
Either move them or leave the hierarchy open.
- **The type is public API and external extension is intended**: Sealing a type
exported from a published package is a **breaking change** for any downstream
package that extends or implements it. Plugin interfaces, visitor bases, and
extension points are meant to be open. Do not seal them to win exhaustiveness.
- **The base type is concrete and instantiated**: `sealed` implies `abstract`.
If callers construct the base type directly, sealing it breaks them, and the
fix is a larger refactor than this skill covers.
- **Fewer than two subtypes**: A single subtype is specialization, not an
algebraic hierarchy. Exhaustiveness checking buys nothing.
- **SDK constraint below 3.0.0**: Class modifiers do not exist before Dart 3.
Check `environment.sdk` in `pubspec.yaml` first.
## 2. Why sealing is the correctness move
The value is not switch syntax. It is that the compiler starts rejecting
incomplete branching:
```
seal the hierarchy -> exhaustiveness checking -> adding a subtype becomes
a compile error at every switch, instead of a silent fallthrough
```
An unsealed hierarchy with `is` cascades is not wrong today. It becomes wrong
the moment someone adds a subtype, and nothing tells them which call sites they
missed. Sealing is prophylactic: it converts a future runtime bug into a
present-day compile error.
This is a different concern from preferring pattern matching for readability.
Pattern matching over an unsealed hierarchy is a style choice. Sealing the
hierarchy is a correctness guarantee, and the ergonomic payoff is a consequence,
not the goal.
## 3. Detection
A type is a candidate when all of the following hold:
1. It is declared `abstract` and is not already `sealed`.
2. It has two or more direct subtypes.
3. Every direct subtype is declared in the **same library** as the base type.
4. Sealing it is not a breaking change: it lives under `lib/src/`, or is already
marked `final`, or the package is an application rather than a published
library.
Conditions 1 through 3 are decidable from the syntax tree. Condition 4 requires
knowing the author's intent about the public API and is the part a human or an
LLM must confirm.
This detection is **not expressible as a single-file regular expression**. It is
a question about the package-wide type graph: you must collect every type
declaration and every `extends`/`implements`/`with` edge before you can tell
whether a hierarchy is closed.
## 4. Applying the change
### Before
```dart
abstract class Shape {}
class Circle extends Shape {
final double radius;
Circle(this.radius);
}
class Square extends Shape {
final double side;
Square(this.side);
}
double area(Shape shape) {
if (shape is Circle) {
return 3.14159 * shape.radius * shape.radius;
} else if (shape is Square) {
return shape.side * shape.side;
}
// Silently reached when a new subtype is added.
throw ArgumentError('Unknown shape: $shape');
}
```
### After
```dart
sealed class Shape {}
final class Circle extends Shape {
final double radius;
Circle(this.radius);
}
final class Square extends Shape {
final double side;
Square(this.side);
}
double area(Shape shape) => switch (shape) {
Circle(:final radius) => 3.14159 * radius * radius,
Square(:final side) => side * side,
};
```
Adding `class Triangle extends Shape` now produces a compile error at `area`,
naming the missing case. The `throw` and its unreachable-by-construction error
message are gone.
### Migration steps
1. Add `sealed` to the base type declaration.
2. Run `dart analyze`. Every non-exhaustive switch over the hierarchy now
reports an error; every subtype declared outside the library reports one too.
3. Convert `is` cascades to `switch` expressions, removing the trailing
`throw`/`default` that existed only to satisfy the return type.
4. Consider marking leaf subtypes `final` to prevent further extension.
## 5. Interaction with other skills
- **`dart-use-pattern-matching`** / **`dart-modern-features`**: apply _after_
sealing. Rewriting an `is` cascade as a switch over an unsealed type is a
readability change only; the same rewrite over a sealed type is checked by the
compiler.
- Do not treat this skill as a reason to seal a hierarchy you do not own the
evolution of.