Skip to content

Dart Inheritance — Subclasses, Overriding, and Polymorphism

DodaTech Updated 2026-06-28 9 min read

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 extends keyword
  • Overriding methods with @override
  • Calling superclass constructors and methods
  • Abstract classes and abstract methods
  • Polymorphism and runtime Type Checking
  • The covariant keyword for parameter type narrowing
  • Preventing inheritance with final classes

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

  1. Overriding non-virtual methods: Dart methods are virtual by default (overridable). Mark methods as final or use the nonvirtual lint rule if they should not be overridden.

  2. 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.

  3. Deep inheritance hierarchies: More than 3-4 levels of inheritance makes code hard to understand and maintain. Prefer composition over deep inheritance.

  4. Using as without checking type first: as throws a runtime error if the cast fails. Use is checks before as casts, or use is with type promotion to avoid casting entirely.

  5. Not marking classes as final when they should not be extended: By default, any class can be extended. Use final class to prevent extension and seal the class hierarchy.

Practice Questions

  1. What is the difference between abstract classes and concrete classes?
  2. How does polymorphism enable writing generic code that works with multiple types?
  3. When should you use super.methodName() inside an overridden method?
  4. Why would you mark a class as final?
  5. Challenge: Design a Vehicle hierarchy with Car, Motorcycle, and Truck subclasses. Each should have a calculateFuelEfficiency method. Use an abstract base class with a common describe method. Demonstrate polymorphism by processing a list of vehicles.

Mini Project

Build a notification system with inheritance:

  • Abstract Notification class with send() method
  • EmailNotification, SMSNotification, PushNotification subclasses
  • Each subclass implements send() differently
  • A NotificationManager that stores and sends all notifications
  • Add a UrgentNotification mixin that changes behavior
  • Write tests verifying each notification type works correctly

FAQ

Does Dart support multiple inheritance?

No. Dart uses single inheritance (one superclass). For sharing behavior across multiple hierarchies, use mixins (the with keyword).

What is the difference between `extends` and `implements`?

extends inherits both interface and implementation. implements inherits only the interface and requires implementing all methods. Use extends for is-a relationships and implements for can-do relationships.

Can I override a static method?

No. Static methods are resolved at compile time and cannot be overridden. They belong to the class, not instances.

What is the `sealed` modifier?

sealed class restricts the class hierarchy. A sealed class can only be extended by classes in the same library. This enables exhaustive pattern matching.

How does Dart resolve method calls at runtime?

Dart uses dynamic dispatch for instance methods. The method called depends on the actual runtime type of the object, not the declared type. This is what enables polymorphism.

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