Class Modifiers in Dart — abstract, base, final, interface, sealed, and mixin
Dart 3 introduced powerful class modifiers that control how your classes can be extended, implemented, and instantiated. Learn when to use each one.
Why class modifiers exist
Before Dart 3, every class was wide open. Anyone could extend it, implement it, or instantiate it. That sounds flexible, but it creates problems.
Consider a library author who writes a DatabaseConnection class. Internally, this class relies on certain methods being called in a specific order. If someone extends it and overrides those methods incorrectly, the whole thing breaks. The library author has no way to prevent this.
// Your library
class DatabaseConnection {
void connect() { /* sets up state */ }
void query(String sql) { /* uses that state */ }
}
// Someone else's code
class MyConnection extends DatabaseConnection {
@override
void connect() {
// Oops, forgot to call super.connect()
// Now query() will crash
}
}
Class modifiers give you control. They let you say: "This class is for implementing, not extending." Or: "This class must stay within my library." Or: "This class cannot be subtyped at all."
Think of modifiers as API contracts. They communicate intent and the compiler enforces it.
The modifier cheat sheet
Dart 3 has six class modifiers. Each one controls different aspects of what code outside your library can do with your class.
The key insight: these restrictions only apply outside the library where the class is defined. Within your own library, you have full access. The modifiers protect your API from external misuse.
abstract — cannot instantiate
The abstract modifier prevents direct instantiation. You cannot call the constructor from outside the class hierarchy.
abstract class Shape {
double get area; // No implementation
void draw(); // Must be overridden
}
class Circle extends Shape {
final double radius;
Circle(this.radius);
@override
double get area => 3.14159 * radius * radius;
@override
void draw() => print('Drawing circle');
}
// var s = Shape(); // Error: abstract class
var c = Circle(5); // OK
Abstract classes can have both abstract members (no implementation) and concrete members (with implementation). Subclasses must implement all abstract members.
Use
abstract when you want to define a template. The class describes what something should do, but subclasses decide how.
base — extend only, no implementing
The base modifier allows extending but blocks implementing. This forces subclasses to inherit your implementation, not just your interface.
// In your library
base class Vehicle {
void startEngine() {
_checkFuel(); // Private method
_initSystems(); // Must run in order
print('Engine started');
}
void _checkFuel() { /* ... */ }
void _initSystems() { /* ... */ }
}
// Outside your library
class Car extends Vehicle { // OK
@override
void startEngine() {
super.startEngine(); // Gets the full sequence
print('Car ready');
}
}
// class FakeVehicle implements Vehicle { } // Error!
Why block implements? Because implementing creates a completely new class that just happens to have the same method signatures. It doesn't get your actual code. If your class relies on internal state or method call sequences, an implementing class will break those assumptions.
Important: subclasses of a
base class must themselves be base, final, or sealed. The protection is transitive.
interface — implement only, no extending
The interface modifier is the opposite of base. It allows implementing but blocks extending.
// In your library
interface class Comparable {
int compareTo(Object other) => 0; // Default impl
}
// Outside your library
class Person implements Comparable { // OK
final String name;
Person(this.name);
@override
int compareTo(Object other) {
if (other is Person) {
return name.compareTo(other.name);
}
return -1;
}
}
// class Employee extends Comparable { } // Error!
Use interface when you want to define a contract without forcing users to inherit your implementation. This is useful when:
1. Your class has implementation details that shouldn't be inherited
2. You want to evolve the implementation without breaking subclasses
3. The class is meant purely as a type/contract
final — complete lockdown
The final modifier is the strictest. It blocks both extending and implementing outside the library. The type is frozen.
// In your library
final class ApiResponse {
final int statusCode;
final String body;
final Map<String, String> headers;
ApiResponse(this.statusCode, this.body, this.headers);
bool get isSuccess => statusCode >= 200 && statusCode < 300;
}
Outside the library, users can create instances and use them normally. But they cannot extend or implement the class.
Use
final when:
1. The class represents a complete, self-contained value (like
DateTime or Uri)
2. You want total control over all instances of the type
3. Subclassing would break invariants or security guarantees
sealed — exhaustive subtypes in one file
The sealed modifier restricts subclasses to the same file. This enables exhaustive pattern matching — the compiler knows all possible subtypes.
// All in one file: result.dart
sealed class Result<T> {}
class Success<T> extends Result<T> {
final T value;
Success(this.value);
}
class Failure<T> extends Result<T> {
final String error;
Failure(this.error);
}
// Usage: compiler knows the switch is exhaustive
String describe<T>(Result<T> result) {
return switch (result) {
Success(:var value) => 'Got: $value',
Failure(:var error) => 'Failed: $error',
// No default needed — compiler knows these are all cases
};
}
Sealed classes are implicitly abstract. You cannot instantiate a sealed class directly — only its subtypes.
Use
sealed for algebraic data types: a fixed set of variants that represent all possible states. Network results, UI states, command types, AST nodes.
mixin class — dual-use declarations
The mixin class modifier creates something that can be both extended (like a class) and mixed in (like a mixin).
mixin class Identifiable {
late final String id = _generateId();
String _generateId() => DateTime.now().toString();
}
// Use as superclass
class Document extends Identifiable {
final String content;
Document(this.content);
}
// Use as mixin
class User extends Person with Identifiable {
final String name;
User(this.name);
}
A mixin class has restrictions: it cannot have an extends clause (must extend Object), and it cannot have a on clause. These are the same restrictions that would apply to using a regular class as a mixin.
Combining modifiers — valid combinations
Modifiers can be combined, but not all combinations are valid. Here are the useful ones:
// Abstract + base: extend only, no instantiation
abstract base class Widget { }
// Abstract + interface: implement only, no instantiation
abstract interface class Serializable { }
// Abstract + final: can only be used within the library
abstract final class InternalBase { }
// Base + mixin: extend or mix in, no implementing
base mixin class Disposable { }
// Interface + mixin: NOT VALID — conflicting constraints
The logic: abstract removes instantiation. base removes implementing. interface removes extending. final removes both. sealed restricts subtypes to the same file.
When choosing, ask yourself:
Should users extend this? If no, use
interface or final.
Should users implement this? If no, use
base or final.
Should users instantiate this? If no, use
abstract.
Are all subtypes known? If yes, use
sealed.
Class Modifiers in Dart
Dart 3 introduced class modifiers that give you fine-grained control over how your classes can be used. This episode covers all six modifiers and when to use each one.
Class Modifiers Quiz
7 questions
Test your understanding of Dart 3 class modifiers