← Back to blog
Object-Oriented Dart · Part 10 of 11
July 3, 202616 min read

Class Modifiers & Sealed Classes in Dart

DartOOPFlutter

Class Modifiers & Sealed Classes

This is Part 10 — the last teaching part — of the Object-Oriented Dart series. We've built classes, constructed them every way possible, encapsulated them, given them rich behavior, and connected them with inheritance, interfaces, mixins, enums, and extensions. (After this comes Part 11, a 100-question mastery bank to put it all to the test.)

Now we reach the most modern OOP feature in the language (introduced in Dart 3): class modifiers. These keywords — abstract, base, interface, final, sealed — let you control exactly how other code is allowed to use your classes. The crown jewel, sealed, unlocks exhaustive pattern matching, which is where Dart's OOP and its functional-style switch expressions meet to produce genuinely bulletproof code. It's the perfect capstone.


Why control how a class is used?

By default in Dart, any class can be extended, implemented, and used as an interface by anyone. That flexibility is fine for app code, but it's a liability when you publish an API or want to reason firmly about a hierarchy:

  • If anyone can extends your class, you can't safely change its internals — a subclass might depend on them (the "fragile base class" problem).
  • If anyone can implements your class as an interface, you can't add a method without breaking all those implementers.
  • If anyone can create arbitrary subtypes, you can never write a switch that's guaranteed to cover them all.

Class modifiers let you opt into restrictions that make these guarantees possible. Think of them as the access-control vocabulary Dart's privacy underscore (Part 3) can't express on its own.

Scope note: these restrictions apply across library (file) boundaries. Inside the same library you can always do whatever you like — modifiers govern how other libraries may use your class.


abstract — can't be instantiated

We met this in Part 6. abstract means the class can't be constructed directly; it exists to be extended or implemented and may contain abstract methods.

abstract class Shape {
  double area(); // contract
}
// Shape(); // ❌ cannot instantiate

This is the one modifier you've already been using. The rest are new.


base — must be inherited, never just implemented

A base class can be extended outside its library but not implemented. That guarantees every instance of the type genuinely runs through your constructor and carries your (possibly private) implementation — no one can fake the type via implements.

base class Vehicle {
  void start() => print('vroom');
}

// In another library:
base class Car extends Vehicle {}      // ✅ extends allowed
// class Fake implements Vehicle {}    // ❌ implements forbidden

A subtype of a base class must itself be base, final, or sealed — the restriction propagates so the guarantee can't leak. Reach for base when your class has invariants enforced in its constructor or relies on private members that an implements-only impostor would skip.


interface — can be implemented, never extended

The mirror image. An interface class can be implemented outside its library but not extended. It says "treat me as a pure contract; don't inherit my implementation."

interface class Logger {
  void log(String msg) => print(msg);
}

// In another library:
class MyLogger implements Logger {     // ✅ implements allowed
  @override
  void log(String msg) => print('LOG: $msg');
}
// class Sub extends Logger {}         // ❌ extends forbidden

This is how you declare "this is meant to be an interface" and have the compiler enforce it — closing the gap from Part 6, where any class could be used either way. It prevents outsiders from depending on your method bodies, so you can change them freely.


final — no subtyping at all (outside the library)

A final class can be neither extended nor implemented outside its library. It's completely closed to outside subtyping. (Note: this final class modifier is unrelated to the final variable keyword — same word, different jobs.)

final class AppConfig {
  final String env;
  AppConfig(this.env);
}

// In another library:
// class Sub extends AppConfig {}      // ❌
// class Impl implements AppConfig {}  // ❌

This gives you maximum freedom to evolve the class: since nobody downstream can subtype it, you can add members, change internals, or refactor without breaking anyone. Use it for concrete types that should be used as-is, not extended — value types, config objects, leaf classes in your design.


Quick comparison

Here's the whole family at a glance — what each modifier permits from another library:

| Modifier | Can extends? | Can implements? | Can instantiate? | | --- | --- | --- | --- | | (none) | ✅ | ✅ | ✅ | | abstract | ✅ | ✅ | ❌ | | base | ✅ | ❌ | ✅ | | interface | ❌ | ✅ | ✅ | | final | ❌ | ❌ | ✅ | | sealed | ❌* | ❌* | ❌ |

* sealed subtypes are allowed only within the same library — which is exactly what makes the next section work.


sealed — a known, closed set of subtypes

Now the star of the show. A sealed class is implicitly abstract (can't be instantiated) and can only be extended or implemented within its own library. Because every subtype must live in the same file, the compiler knows the complete, finite list of subtypes — and can check that you've handled all of them.

sealed class Shape {}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);
}

class Square extends Shape {
  final double side;
  Square(this.side);
}

class Rectangle extends Shape {
  final double w, h;
  Rectangle(this.w, this.h);
}

sealed is like an enum, but for types: a closed set of known variants, each of which can carry its own fields and structure. That combination — closed set + rich per-variant data — is what makes it so powerful.


The payoff: exhaustive pattern matching

Because the compiler knows every Shape subtype, a switch over a sealed type gets exhaustiveness checking — and you don't need a default:

double area(Shape shape) {
  return switch (shape) {
    Circle(:final radius) => 3.14159 * radius * radius,
    Square(:final side) => side * side,
    Rectangle(:final w, :final h) => w * h,
    // No default needed — the compiler knows these are ALL the shapes.
  };
}

Look at what's happening:

  • Each case is an object pattern like Circle(:final radius), which simultaneously checks the type and destructures the field into a local radius. No casting, no shape.radius — the value is extracted for you. (This :final field shorthand binds a variable with the same name as the field.)
  • There's no default, and that's the whole point. If you later add class Triangle extends Shape, this switch becomes a compile error: "the type 'Triangle' is not exhaustively matched." The compiler marches you to every switch that needs the new case.

This is a profound safety property. With an open class hierarchy you'd write a default that silently swallows unexpected types at runtime. With sealed, the compiler guarantees you've considered every possibility — turning a class of "I forgot to handle that" bugs into compile errors. It's the type-safe, OOP-native way to model "a value that is exactly one of these known cases."

A real-world shape: API state

The pattern you'll reach for most is modelling the state of an async operation:

sealed class Result<T> {}

class Loading<T> extends Result<T> {}

class Success<T> extends Result<T> {
  final T data;
  Success(this.data);
}

class Failure<T> extends Result<T> {
  final String message;
  Failure(this.message);
}

String render(Result<String> state) => switch (state) {
      Loading() => 'Spinner...',
      Success(:final data) => 'Got: $data',
      Failure(:final message) => 'Error: $message',
    };

void main() {
  print(render(Loading()));            // Spinner...
  print(render(Success('hello')));     // Got: hello
  print(render(Failure('timeout')));   // Error: timeout
}

Every screen that renders a Result is forced to handle loading, success, and failure — you can't ship a UI that forgets the error state. This sealed-Result pattern is wildly popular in modern Flutter apps, and now you know exactly why it works.


Combining modifiers

Modifiers compose, in a fixed order: abstract first, then one of base/interface/final/sealed, then optionally mixin, then class.

abstract base class Repository {} // can't instantiate AND can't implement
abstract interface class Service {} // a pure, non-instantiable contract
base mixin class Helper {}          // a base class that's also a mixin

A couple of useful combinations:

  • abstract interface class — the cleanest way to declare a "pure interface": no implementation to inherit, no instances, implement-only. This is what most people mean when they say "interface."
  • abstract base class — a base for a hierarchy that shares implementation and forbids outside implements, but isn't itself constructible.

Some combinations are illegal (e.g. sealed is already implicitly abstract, so abstract sealed is redundant; mixin can't combine with interface/final/sealed). The analyzer will tell you — you don't need to memorize the matrix, just know the order and that not all pairs are valid.


How to choose — a decision guide

For a class you're designing, ask:

  1. Should outsiders create instances? No → consider abstract (or sealed).
  2. Do I need a guaranteed-complete set of subtypes for exhaustive switch? Yes → sealed (keep all variants in one library).
  3. Is this purely a contract others implement?abstract interface class.
  4. Do subtypes need my real implementation/invariants, and must not be faked?base.
  5. Should this be used exactly as-is, never subtyped, so I can evolve it freely?final.
  6. None of the above / it's just app code? → no modifier. Don't over-restrict internal classes; modifiers earn their keep mostly in shared/published APIs and in sealed hierarchies.

Practice Challenges

Challenge 1 — Sealed + exhaustive switch. Model a sealed class Animal with Dog, Cat, and Bird subtypes, and a String sound(Animal) using an exhaustive switch (no default).

Show solution
sealed class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}
class Bird extends Animal {}

String sound(Animal a) => switch (a) {
      Dog() => 'Woof',
      Cat() => 'Meow',
      Bird() => 'Tweet',
    };

void main() => print(sound(Cat())); // Meow

Because Animal is sealed and all subtypes are in this file, the switch is exhaustive without a default. Add a Fish and the switch won't compile until you handle it.

Challenge 2 — Destructure in the pattern. Extend Challenge 1 so Dog has a name and the switch returns "Rex says Woof".

Show solution
sealed class Animal {}
class Dog extends Animal {
  final String name;
  Dog(this.name);
}
class Cat extends Animal {}

String sound(Animal a) => switch (a) {
      Dog(:final name) => '$name says Woof',
      Cat() => 'Meow',
    };

void main() => print(sound(Dog('Rex'))); // Rex says Woof

Dog(:final name) checks the type and pulls out the name field in one step — no cast, no separate (a as Dog).name.

Challenge 3 — Pick the modifier (final). You ship a concrete Money value type and never want anyone to subtype it, so you can keep evolving it. Which modifier, and write the header.

Show solution
final class Money {
  final int cents;
  const Money(this.cents);
}

final (the class modifier) forbids both extends and implements from other libraries, so Money can only be used as-is — leaving you free to change its internals without breaking downstream code.

Challenge 4 — Pick the modifier (interface). You want a PaymentGateway that others implement but never extend. Write it as a pure interface.

Show solution
abstract interface class PaymentGateway {
  Future<bool> charge(int cents);
}

// elsewhere:
class StripeGateway implements PaymentGateway {
  @override
  Future<bool> charge(int cents) async => true;
}

abstract interface class is the idiomatic "pure interface": can't be instantiated, can't be extended from outside, implement-only. The compiler enforces the intent that Part 6's plain abstract classes only suggested.

Challenge 5 — Sealed Result. Build a sealed class Result<T> with Ok<T>(value) and Err<T>(error), and a function that maps a Result<int> to a user-facing string.

Show solution
sealed class Result<T> {}
class Ok<T> extends Result<T> {
  final T value;
  Ok(this.value);
}
class Err<T> extends Result<T> {
  final String error;
  Err(this.error);
}

String describe(Result<int> r) => switch (r) {
      Ok(:final value) => 'Success: $value',
      Err(:final error) => 'Failed: $error',
    };

void main() {
  print(describe(Ok(42)));        // Success: 42
  print(describe(Err('boom')));   // Failed: boom
}

Two variants, exhaustively matched. This is the backbone of error handling without exceptions — every caller must deal with both the success and failure case.

Challenge 6 — Capstone. Model a tiny expression evaluator: a sealed class Expr with Num(value), Add(left, right), and Mul(left, right), plus a recursive int eval(Expr). Evaluate (2 + 3) * 4.

Show solution
sealed class Expr {}

class Num extends Expr {
  final int value;
  Num(this.value);
}

class Add extends Expr {
  final Expr left, right;
  Add(this.left, this.right);
}

class Mul extends Expr {
  final Expr left, right;
  Mul(this.left, this.right);
}

int eval(Expr e) => switch (e) {
      Num(:final value) => value,
      Add(:final left, :final right) => eval(left) + eval(right),
      Mul(:final left, :final right) => eval(left) * eval(right),
    };

void main() {
  final expr = Mul(Add(Num(2), Num(3)), Num(4)); // (2 + 3) * 4
  print(eval(expr)); // 20
}

This is the textbook example of why sealed classes are beautiful: a closed set of node types, each carrying its own structure, evaluated with an exhaustive recursive switch. Add a Sub node and the compiler instantly tells you eval is incomplete. You've just written a miniature interpreter — and it brings together constructors, polymorphism, sealed types, and pattern matching from across the whole series.


Questions to test yourself

Q1 (basic). What does the sealed modifier do to a class?

Show answer

It makes the class implicitly abstract (no direct instances) and restricts its subtypes to the same library. Because the full set of subtypes is then known to the compiler, you get exhaustiveness checking in switch statements over that type.

Q2 (basic). What's the difference between base, interface, and final in one line each?

Show answer

base → can be extended but not implemented (outside the library). interface → can be implemented but not extended. final → can be neither extended nor implemented (fully closed to outside subtyping).

Q3 (intermediate). Why does a switch over a sealed type not need a default, and why is omitting default actually safer?

Show answer

A sealed type's subtypes are all in one library, so the compiler knows the complete set and can verify your switch covers every one — no default required. Omitting default is safer because if you add a new subtype later, the compiler flags every now-incomplete switch as an error, forcing you to handle the new case. A default would silence that check and let the missing case slip through to runtime.

Q4 (intermediate). What does the object pattern Circle(:final radius) do inside a switch case?

Show answer

Two things at once: it matches when the value is a Circle, and it destructures that circle's radius field into a local variable named radius — so you can use radius directly in the case body without any cast or field access. The :final name shorthand binds a variable with the same name as the field.

Q5 (advanced). You're publishing a library and want a type others can implement (for testing/mocking) but never extend, and that you can add methods to later without breaking them. Wait — adding a method does break implementers. Reconcile this: which modifier fits, and what's the real trade-off?

Show answer

interface (typically abstract interface class) fits the "implement-only" requirement. But the catch is real: any class that implements your interface must provide every member, so adding a method is a breaking change for them. The trade-off is the classic one between interface and final/base: interface maximizes implementer flexibility (great for mockability and dependency inversion) at the cost of your freedom to add members; if instead you want freedom to evolve the type, you'd close it with final/base and not expose it as an interface. There's no modifier that gives both — you choose which flexibility matters more.

Q6 (advanced). Compare modelling a closed set of cases with an enum versus a sealed class. When does each win?

Show answer

Both give a closed set with exhaustive switch. An enum wins when the cases are fixed singleton values that all share the same shape (same fields), like TrafficLight or Priority — they're lightweight and each value is a single constant instance. A sealed class wins when the cases have different structure / per-variant data and behavior and you may need multiple instances of each (e.g. Success(data) vs Failure(message), or expression nodes carrying sub-expressions). Rule of thumb: same shape, finite constants → enum; different shapes or instance data → sealed class.


Wrapping up — and the whole series

Class modifiers give you precise control over how your types may be used:

  • abstract — no instances. base — extend-only. interface — implement-only. final — fully closed. sealed — closed set of same-library subtypes, implicitly abstract.
  • sealed + switch gives exhaustive pattern matching: the compiler guarantees every case is handled, and object patterns (Circle(:final radius)) check-and-destructure in one step.
  • Combine modifiers in order (abstract base class, abstract interface class); don't over-restrict ordinary app code.

And that completes Object-Oriented Dart. Look how far you've come from Part 1:

  1. Thinking in objects — classes, objects, this.
  2. Constructors — generative, named, const, factory.
  3. Encapsulation — privacy, getters/setters, statics.
  4. Methods & operators — overloading, ==/hashCode, cascades.
  5. Inheritance & polymorphismextends, super, @override.
  6. Abstract classes & interfaces — contracts and implements.
  7. Mixinswith, on, reusable behavior.
  8. Enhanced enums — closed sets that act like classes.
  9. Extensions — adding to types you don't own.
  10. Class modifiers & sealed classes — controlling and closing your designs.

You now have the complete object-oriented toolkit that real Dart and Flutter codebases are built from. But reading isn't mastering — so the series ends with a gauntlet. Head to Part 11: 100 Questions to Master OOP in Dart, a graded bank of theory and coding problems (with hints, not full solutions) covering everything from Part 1 to here. Work through it and you won't just recognize these concepts — you'll wield them.