Dart Inheritance — Subclasses, Overriding, and Polymorphism
In this tutorial, you will learn about Dart Inheritance. We cover key concepts, practical examples, and best practices to help you master this topic.
Dart inheritance allows a class to extend another class, inheriting its fields and methods while supporting method overriding, superclass constructors, and abstract class hierarchies.
What You Will Learn
- Extending classes with the
extendskeyword - Overriding methods with
@override - Calling superclass constructors and methods
- Abstract classes and abstract methods
- Polymorphism and runtime Type Checking
- The
covariantkeyword for parameter type narrowing - Preventing inheritance with
finalclasses
Why It Matters
Inheritance is a fundamental principle of object-oriented programming that enables code reuse and establishes type hierarchies. In Dart, inheritance is used extensively in Flutter's widget hierarchy, where specialized widgets extend base widget classes. Understanding how to properly design and use inheritance hierarchies helps you avoid common pitfalls like the fragile base class problem and ensures your code is extensible.
Real-World Use
The DodaTech Flutter app defines a BaseViewModel abstract class that provides common state management logic. Concrete ViewModels like LoginViewModel, HomeViewModel, and SettingsViewModel extend the base class and override the onInit and onDispose lifecycle methods. Flutter itself uses inheritance extensively: StatelessWidget, StatefulWidget, and their many subclasses.
Learning Path
flowchart LR A[Dart Classes] --> B[Inheritance\nYou are here] B --> C[Dart Interfaces] style B fill:#f90,color:#fff
Basic Inheritance
A class inherits from another using the extends keyword. The subclass receives all non-private fields and methods from the superclass:
class Animal {
String name;
Animal(this.name);
void makeSound() {
print('$name makes a sound');
}
}
class Dog extends Animal {
Dog(String name) : super(name);
void wagTail() {
print('$name wags tail');
}
}
void main() {
var dog = Dog('Buddy');
dog.makeSound(); // Inherited from Animal
dog.wagTail(); // Defined in Dog
}
Output:
Buddy makes a sound
Buddy wags tail
The subclass constructor calls the superclass constructor using super(name). All public and protected methods from Animal are available on Dog instances.
Method Overriding
Subclasses can override methods to change or extend behavior:
class Animal {
String name;
Animal(this.name);
void makeSound() {
print('$name makes a generic sound');
}
}
class Dog extends Animal {
Dog(String name) : super(name);
@override
void makeSound() {
print('$name barks');
}
}
class Cat extends Animal {
Cat(String name) : super(name);
@override
void makeSound() {
print('$name meows');
}
}
void main() {
var animals = [Dog('Buddy'), Cat('Whiskers'), Animal('Generic')];
for (var animal in animals) {
animal.makeSound();
}
}
Output:
Buddy barks
Whiskers meows
Generic makes a generic sound
The @override annotation is optional but recommended. It tells the compiler that the method intentionally overrides a superclass method. The correct override is selected at runtime based on the actual type of the object.
Calling Superclass Methods
Use super.methodName() to call the overridden method from the superclass:
class Animal {
String name;
Animal(this.name);
void makeSound() {
print('$name makes a sound');
}
}
class Dog extends Animal {
Dog(String name) : super(name);
@override
void makeSound() {
print('$name prepares to bark...');
super.makeSound(); // Call Animal's version
print('...bark completed!');
}
}
void main() {
var dog = Dog('Buddy');
dog.makeSound();
}
Output:
Buddy prepares to bark...
Buddy makes a sound
...bark completed!
Calling super.makeSound() inside the overridden method lets you extend the parent's behavior rather than replacing it entirely.
Abstract Classes
Abstract classes define a contract that subclasses must fulfill. They cannot be instantiated directly:
abstract class Shape {
// Abstract method (no body)
double area();
// Concrete method
void describe() {
print('This shape has area: ${area()}');
}
}
class Circle extends Shape {
final double radius;
Circle(this.radius);
@override
double area() => 3.14159 * radius * radius;
}
class Rectangle extends Shape {
final double width;
final double height;
Rectangle(this.width, this.height);
@override
double area() => width * height;
}
void main() {
var shapes = [Circle(5), Rectangle(4, 6)];
for (var shape in shapes) {
shape.describe();
}
}
Output:
This shape has area: 78.53975
This shape has area: 24
Abstract methods have no body and must be implemented by non-abstract subclasses. Abstract classes can have both abstract and concrete methods, providing a mix of contract and shared implementation.
Polymorphism
Polymorphism lets you treat objects of different types through a common interface:
abstract class PaymentMethod {
double processPayment(double amount);
}
class CreditCard extends PaymentMethod {
@override
double processPayment(double amount) {
var fee = amount * 0.02;
print('Processing credit card: \$${amount + fee}');
return amount + fee;
}
}
class PayPal extends PaymentMethod {
@override
double processPayment(double amount) {
var fee = amount * 0.01;
print('Processing PayPal: \$${amount + fee}');
return amount + fee;
}
}
class Cash extends PaymentMethod {
@override
double processPayment(double amount) {
print('Processing cash: \$$amount');
return amount; // No fee
}
}
void main() {
List<PaymentMethod> methods = [CreditCard(), PayPal(), Cash()];
var total = 0.0;
for (var method in methods) {
total += method.processPayment(100.0);
}
print('Total collected: \$$total');
}
Output:
Processing credit card: $102.0
Processing PayPal: $101.0
Processing cash: $100.0
Total collected: $303.0
The PaymentMethod interface defines the contract. Each subclass implements processPayment differently. The loop treats all payment methods uniformly, yet each object behaves according to its actual type.
Type Checking and Casting
Use is, as, and runtime type checks to work with object types:
abstract class Animal {
void makeSound();
}
class Dog extends Animal {
@override
void makeSound() => print('Bark');
void fetch() => print('Fetching the ball');
}
class Cat extends Animal {
@override
void makeSound() => print('Meow');
void purr() => print('Purring');
}
void main() {
Animal pet = Dog();
// Type check with is
if (pet is Dog) {
pet.fetch(); // Automatically promoted to Dog
}
// Type cast with as
var dog = pet as Dog;
dog.fetch();
// Checking exact type
print('Is Dog? ${pet is Dog}');
print('Type: ${pet.runtimeType}');
// Using switch with sealed hierarchy
Animal another = Cat();
switch (another) {
case Dog _:
print('Got a dog');
case Cat _:
print('Got a cat');
}
}
Output:
Fetching the ball
Fetching the ball
Is Dog? true
Type: Dog
Got a cat
The is check promotes the variable to the checked type within the branch, so no explicit cast is needed. The as operator performs an explicit cast that throws TypeError if the object is not of the expected type.
The Covariant Keyword
The covariant keyword allows narrowing a parameter type in an overridden method:
class Animal {
void feed(Animal food) {
print('Feeding animal with ${food.runtimeType}');
}
}
class Dog extends Animal {
@override
void feed(covariant DogFood food) {
print('Dog eating ${food.brand} dog food');
}
}
class Cat extends Animal {
@override
void feed(covariant CatFood food) {
print('Cat eating ${food.brand} cat food');
}
}
class DogFood {
final String brand;
DogFood(this.brand);
}
class CatFood {
final String brand;
CatFood(this.brand);
}
Without covariant, overriding with a narrower parameter type is an error. covariant tells the compiler that the override is intentional and the caller is responsible for passing the correct type.
Preventing Inheritance
Mark a class final to prevent it from being extended:
final class MathUtils {
static double pi = 3.14159;
static int add(int a, int b) => a + b;
}
// The following would cause a compile error:
// class BetterMath extends MathUtils {}
class Utility {
String name;
Utility(this.name);
}
// This is allowed because Utility is not final
class SpecialUtility extends Utility {
SpecialUtility(String name) : super(name);
}
Using final on classes that are not designed for inheritance is a best practice. It prevents fragile base class problems and makes the API contract clear.
Constructor Inheritance
Subclass constructors must call a superclass constructor:
class Person {
String name;
int age;
Person(this.name, this.age);
Person.withAge(this.name, int age) : age = age >= 0 ? age : 0;
}
class Employee extends Person {
String company;
// Must call super constructor
Employee(String name, int age, this.company) : super(name, age);
// Named constructor forwarding to super
Employee.manager(String name, int age)
: company = 'Management Inc.',
super(name, age);
// Constructor redirecting to another constructor
Employee.unknown() : this('Unknown', 0, 'Unassigned');
}
void main() {
var emp = Employee('Alice', 30, 'DodaTech');
var mgr = Employee.manager('Bob', 40);
print('${emp.name} works at ${emp.company}');
print('${mgr.name} works at ${mgr.company}');
}
Output:
Alice works at DodaTech
Bob works at Management Inc.
The subclass constructor must have a super() call in its initializer list. Named subclass constructors can call different superclass constructors.
Common Mistakes
Overriding non-virtual methods: Dart methods are virtual by default (overridable). Mark methods as
finalor use thenonvirtuallint rule if they should not be overridden.Forgetting to call super in constructor: Every subclass constructor must call a superclass constructor. If the superclass has no default constructor, the subclass must explicitly call a named superclass constructor.
Deep inheritance hierarchies: More than 3-4 levels of inheritance makes code hard to understand and maintain. Prefer composition over deep inheritance.
Using
aswithout checking type first:asthrows a runtime error if the cast fails. Useischecks beforeascasts, or useiswith type promotion to avoid casting entirely.Not marking classes as
finalwhen they should not be extended: By default, any class can be extended. Usefinal classto prevent extension and seal the class hierarchy.
Practice Questions
- What is the difference between abstract classes and concrete classes?
- How does polymorphism enable writing generic code that works with multiple types?
- When should you use
super.methodName()inside an overridden method? - Why would you mark a class as
final? - Challenge: Design a
Vehiclehierarchy withCar,Motorcycle, andTrucksubclasses. Each should have acalculateFuelEfficiencymethod. Use an abstract base class with a commondescribemethod. Demonstrate polymorphism by processing a list of vehicles.
Mini Project
Build a notification system with inheritance:
- Abstract
Notificationclass withsend()method EmailNotification,SMSNotification,PushNotificationsubclasses- Each subclass implements
send()differently - A
NotificationManagerthat stores and sends all notifications - Add a
UrgentNotificationmixin that changes behavior - Write tests verifying each notification type works correctly
FAQ
What is Next
Now that you understand inheritance, learn about interfaces and abstract contracts. Proceed to Dart Interfaces for implementing interfaces and the implements keyword. Then explore Dart Mixins for reusable behavior composition.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro