Abstract Classes & Interfaces
This is Part 6 of the Object-Oriented Dart series. We've used inheritance and polymorphism (Part 5). Now we make the fourth pillar — abstraction — explicit.
Abstraction means modelling the idea of a thing without committing to a concrete version: a "Shape" without saying which shape, a "PaymentMethod" without saying which one. Dart gives you two tools for this — abstract classes and interfaces — and a subtle-but-important distinction between extends and implements. This part clears up confusion that trips up even experienced developers.
The problem abstraction solves
In Part 5 our base Shape had a placeholder area() returning 0:
class Shape {
double area() => 0; // meaningless default
}
That's a code smell. What is the area of a generic "shape"? There's no sensible answer — a plain Shape shouldn't exist on its own, and that 0 is a bug waiting to be forgotten in a subclass. We want to say two things:
- You can't create a bare
Shape— it's only a concept. - Every real shape must provide its own
area()— no default.
That's exactly what abstract classes express.
Abstract classes
Mark a class abstract and it cannot be instantiated directly. It exists only to be extended (or implemented):
abstract class Shape {
double area(); // an ABSTRACT method — no body, just a contract
// Abstract classes CAN have concrete members too:
void describe() => print('A shape with area ${area()}');
}
Two new things:
double area();ends in a semicolon with no body. That's an abstract method — it declares "every subclass must implement this" without saying how. Abstract methods can only live in abstract classes (or mixins).describe()is a normal, concrete method. Abstract classes are free to mix abstract declarations with fully-implemented behavior. That's their superpower over pure interfaces: shared implementation plus enforced contracts.
Now this is a compile error:
var s = Shape(); // ❌ Error: Abstract classes can't be instantiated.
And subclasses are forced to fill in area():
class Circle extends Shape {
final double radius;
Circle(this.radius);
@override
double area() => 3.14159 * radius * radius; // required — or it won't compile
}
class Square extends Shape {
final double side;
Square(this.side);
@override
double area() => side * side;
}
void main() {
final shapes = <Shape>[Circle(2), Square(3)];
for (final s in shapes) {
s.describe(); // inherited concrete method, using each shape's own area()
}
}
If a subclass forgets area(), the compiler stops you immediately. The placeholder-0 bug is now impossible. That's abstraction enforcing a contract.
When to use an abstract class: when you have a family of related types that share some implementation but each must supply some specifics.
Shape(shareddescribe(), requiredarea()) is the textbook case.
Every Dart class is also an interface
Here's the idea that makes Dart's type system click, and that most newcomers miss:
Every class in Dart implicitly defines an interface — the set of its instance methods, getters, and setters. There's no separate
interfacekeyword like in Java; the interface of a class is just its public member signatures.
So any class — abstract or not — can be used as an interface that other classes promise to satisfy with the implements keyword:
class Duck {
void quack() => print('Quack!');
void swim() => print('Paddle paddle');
}
// Person doesn't extend Duck, but promises to provide its interface.
class Person implements Duck {
@override
void quack() => print('The person imitates: Quack!');
@override
void swim() => print('The person swims');
}
Person is not a Duck and inherits no code from it — but because it implements Duck, it must provide every member of Duck's interface. This is "duck typing" made type-safe: if it implements the Duck interface, you can use it anywhere a Duck is expected.
extends vs. implements — the key distinction
This is the single most important takeaway of this part. Both let a type stand in for another, but they differ fundamentally:
| | extends (inheritance) | implements (interface) |
| --- | --- | --- |
| Inherits implementation? | Yes — gets the parent's method bodies, fields | No — must re-implement every member itself |
| How many allowed? | Only one superclass | Many interfaces at once |
| Can call super.method()? | Yes | No (nothing to call up to) |
| Relationship | "is-a", reusing code | "behaves-as", fulfilling a contract |
Compare directly:
class Animal {
void breathe() => print('breathing');
void move() => print('moving');
}
// EXTENDS: Dog IS an Animal, and reuses its code.
class Dog extends Animal {
void bark() => print('woof');
// breathe() and move() are inherited — free.
}
// IMPLEMENTS: Robot is NOT an Animal but mimics its interface.
class Robot implements Animal {
@override
void breathe() => print('venting heat'); // MUST provide — no inheritance
@override
void move() => print('rolling'); // MUST provide
}
Dog gets breathe()/move() for free. Robot inherits nothing and must implement both, but it's now usable wherever an Animal is expected. Use extends to reuse implementation; use implements to promise a contract.
Implementing multiple interfaces
Because you can implement many interfaces, you compose capabilities:
abstract class Drawable {
void draw();
}
abstract class Clickable {
void onClick();
}
class Button implements Drawable, Clickable {
@override
void draw() => print('Drawing button');
@override
void onClick() => print('Clicked!');
}
Button is both Drawable and Clickable. A function taking a Drawable accepts it; so does one taking a Clickable. This sidesteps Dart's single-inheritance limit when all you need is to promise capabilities rather than inherit code.
Pure interfaces in practice
You can use a concrete class as an interface, but it's cleaner to define interfaces as abstract classes with only abstract methods — they read as "pure contracts" with nothing to instantiate and nothing to inherit:
abstract class Repository<T> {
Future<T?> findById(String id);
Future<List<T>> findAll();
Future<void> save(T item);
Future<void> delete(String id);
}
class UserRepository implements Repository<User> {
@override
Future<User?> findById(String id) async { /* hit the database */ }
@override
Future<List<User>> findAll() async { /* ... */ }
@override
Future<void> save(User item) async { /* ... */ }
@override
Future<void> delete(String id) async { /* ... */ }
}
This is the backbone of testable architecture: code depends on the Repository interface, and you can swap a real UserRepository for a fake one in tests — no code change in between. (We'll see in Part 10 that Dart even has an interface modifier to make this intent explicit and enforce it.)
Abstract class and interface — used together
These aren't either/or. A common, powerful pattern: an abstract class provides shared behavior and serves as the interface, while a factory constructor hands back concrete implementations the caller never names directly:
abstract class Logger {
void log(String message);
// A factory on an abstract class — returns a concrete subtype.
factory Logger(String type) {
return switch (type) {
'console' => ConsoleLogger(),
'silent' => SilentLogger(),
_ => ConsoleLogger(),
};
}
}
class ConsoleLogger implements Logger {
@override
void log(String message) => print('[LOG] $message');
}
class SilentLogger implements Logger {
@override
void log(String message) {} // does nothing
}
void main() {
final logger = Logger('console'); // get a Logger, don't care which
logger.log('Hello'); // [LOG] Hello
}
The caller works entirely against the Logger abstraction; the concrete classes stay hidden behind the factory. Abstraction (the contract) and the factory (Part 2) working together.
Practice Challenges
Challenge 1 — Make it abstract. Turn this into an abstract class so Animal() can't be created and every subclass must define makeSound().
class Animal {
String makeSound() => '';
}
Show solution
abstract class Animal {
String makeSound(); // abstract — no body
}
class Cat extends Animal {
@override
String makeSound() => 'Meow';
}
Marking the class abstract blocks Animal(), and the bodiless makeSound(); forces every subclass to implement it. The meaningless empty-string default is gone.
Challenge 2 — Concrete + abstract mix. Add a concrete introduce() method to the abstract Animal that prints "I say <sound>" using the abstract makeSound().
Show solution
abstract class Animal {
String makeSound();
void introduce() => print('I say ${makeSound()}');
}
class Dog extends Animal {
@override
String makeSound() => 'Woof';
}
void main() {
Dog().introduce(); // I say Woof
}
introduce() is fully implemented in the abstract class but relies on the abstract makeSound() — the subclass fills in the missing piece, and polymorphism wires them together.
Challenge 3 — implements a contract. Define an abstract Comparable2 interface with int compareTo(other), and a Weight class that implements it.
Show solution
abstract class Comparable2 {
int compareTo(covariant Comparable2 other);
}
class Weight implements Comparable2 {
final double kg;
Weight(this.kg);
@override
int compareTo(covariant Weight other) => kg.compareTo(other.kg);
}
Weight inherits no code — implements forces it to provide compareTo itself. (covariant lets the override narrow the parameter to Weight, as we saw in Part 5. In real code you'd just use Dart's built-in Comparable<T>.)
Challenge 4 — Multiple interfaces. Create Flyer (fly()) and Swimmer (swim()) interfaces, and a Duck class that is both.
Show solution
abstract class Flyer {
void fly();
}
abstract class Swimmer {
void swim();
}
class Duck implements Flyer, Swimmer {
@override
void fly() => print('Flap flap');
@override
void swim() => print('Paddle paddle');
}
void main() {
Duck()
..fly()
..swim();
}
A class can implement any number of interfaces, composing capabilities without inheriting code from any of them.
Challenge 5 — extends vs implements. Given class Engine { void start() => print('vroom'); }, write a Car that reuses start() and a SimulatedEngine that re-implements it. Which keyword for each?
Show solution
class Engine {
void start() => print('vroom');
}
// Reuse Engine's code → extends
class TurboEngine extends Engine {
void boost() => print('TURBO');
// start() inherited, prints 'vroom'
}
// Re-implement the interface → implements
class SimulatedEngine implements Engine {
@override
void start() => print('fake vroom'); // must provide its own
}
extends inherits start() unchanged; implements inherits nothing and must supply every member. Choose extends when you want the existing behavior, implements when you only want to honor the contract with your own behavior. (Strictly, the Car-has-an-Engine relationship is really composition — a field — but this shows the keyword contrast.)
Challenge 6 — Design a plugin system. Sketch an abstract Plugin interface with name (getter) and run(), plus two concrete plugins, and a function that runs a List<Plugin> polymorphically.
Show solution
abstract class Plugin {
String get name;
void run();
}
class BackupPlugin implements Plugin {
@override
String get name => 'Backup';
@override
void run() => print('Backing up...');
}
class CleanupPlugin implements Plugin {
@override
String get name => 'Cleanup';
@override
void run() => print('Cleaning up...');
}
void runAll(List<Plugin> plugins) {
for (final p in plugins) {
print('Running ${p.name}');
p.run();
}
}
void main() {
runAll([BackupPlugin(), CleanupPlugin()]);
}
runAll depends only on the Plugin abstraction. New plugins (just implement Plugin) drop in with zero changes to runAll — abstraction plus polymorphism giving you an open, extensible system.
Questions to test yourself
Q1 (basic). What does marking a class abstract prevent, and why is that useful?
Show answer
It prevents direct instantiation — you can't write Shape(). That's useful when a type represents only a concept (a generic "Shape") that shouldn't exist on its own, forcing callers to use a concrete subclass and ensuring required abstract methods are actually implemented.
Q2 (basic). What is an abstract method?
Show answer
A method declared with no body (just a signature ending in ;) inside an abstract class or mixin. It defines a contract — every concrete subclass must provide an implementation — without specifying how.
Q3 (intermediate). Dart has no interface keyword for defining interfaces. So where do interfaces come from?
Show answer
Every class implicitly defines an interface consisting of its instance member signatures. Any class can be used as an interface via implements. By convention, "pure" interfaces are written as abstract classes containing only abstract methods. (Dart 3 does add an interface class modifier — Part 10 — but that restricts how a class may be used, it isn't how you declare an interface.)
Q4 (intermediate). What are the three biggest differences between extends and implements?
Show answer
(1) extends inherits the parent's implementation; implements inherits nothing and you must re-implement every member. (2) You can extends only one class but implements many interfaces. (3) With extends you can call super.method(); with implements there's no super implementation to call.
Q5 (advanced). When you implements a concrete class (one that has method bodies), do you get those bodies? Why does that make implementing a class with many concrete methods tedious?
Show answer
No — implements ignores the implementation entirely and only takes the interface (the signatures). So if you implements a concrete class with ten methods, you must provide your own version of all ten, even the ones you'd have been happy to reuse. That's exactly why you reach for extends (or a mixin) when you want to reuse code, and implements only when you genuinely want a fresh implementation of the contract.
Q6 (advanced). You need a type to share code with a base and be substitutable for several unrelated contracts. How do you combine extends and implements, and in what order?
Show answer
A class can do both at once: class Button extends Widget implements Drawable, Clickable { ... }. The extends clause comes first (one superclass, whose code you inherit), followed by implements with a comma-separated list of interfaces (each of which you must fully satisfy). So Button reuses Widget's implementation while also promising the Drawable and Clickable contracts.
Wrapping up
Abstraction lets you program against ideas, not implementations:
- Abstract classes (
abstract class) can't be instantiated and may mix abstract methods (contracts) with concrete shared behavior. - Every class is also an interface — there's no
interfacekeyword for declaring one; use abstract classes with only abstract methods for "pure" contracts. extendsinherits implementation (one parent,superavailable);implementsinherits nothing but lets you satisfy many contracts.- Depend on abstractions, not concretions — it's what makes code testable and extensible (swap real for fake, add new types freely).
In Part 7 we tackle Dart's answer to "I want to reuse behavior across unrelated classes without single-inheritance pain": mixins — with, on, and mixin class.