← Back to blog
Object-Oriented Dart · Part 5 of 11
June 28, 202613 min read

Inheritance & Polymorphism in Dart

DartOOPFlutter

Inheritance & Polymorphism

This is Part 5 of the Object-Oriented Dart series, and it's a big one — we cover two of OOP's four pillars at once, because they're best understood together. Inheritance lets a class build on another so you don't repeat yourself. Polymorphism lets different objects respond to the same call in their own way. Together they're the engine behind a huge amount of real-world OOP, Flutter included (every widget you write extends something).


The "is-a" relationship

Inheritance models an "is-a" relationship. A Dog is an Animal. A SavingsAccount is a BankAccount. The general class is the superclass (or parent/base); the specialized one is the subclass (or child/derived).

The subclass automatically gets everything public from its parent — fields, methods, getters — and can add its own or change inherited behavior. You create the relationship with extends:

class Animal {
  final String name;
  Animal(this.name);

  void breathe() => print('$name is breathing');
  void describe() => print('I am $name, an animal.');
}

class Dog extends Animal {
  Dog(String name) : super(name); // pass `name` up to Animal

  void bark() => print('$name says woof!');
}

void main() {
  var d = Dog('Rex');
  d.breathe();  // inherited from Animal → Rex is breathing
  d.bark();     // Dog's own method   → Rex says woof!
}

Dog didn't define breathe() — it inherited it. That reuse is the first payoff of inheritance.

Design tip: only use inheritance for genuine "is-a" relationships. A Car is not a Engine — a car has an engine. That "has-a" relationship calls for a field (composition), not extends. Overusing inheritance for code-sharing where there's no real "is-a" leads to brittle hierarchies. We'll see composition-friendly tools (mixins) in Part 7.


super — talking to the parent

You already saw super(name) in the constructor. super is your handle on the superclass, and it shows up in two places.

1. Forwarding to the parent constructor

A subclass constructor must make sure the parent gets constructed. You call the parent's constructor in the initializer list with super(...):

class Animal {
  final String name;
  Animal(this.name);
}

class Dog extends Animal {
  final String breed;
  Dog(String name, this.breed) : super(name);
}

Modern Dart lets you forward parameters more concisely with super parameters (from Part 2):

class Dog extends Animal {
  final String breed;
  Dog(super.name, this.breed); // super.name feeds Animal(this.name)
}

If the parent has an unnamed, zero-argument constructor, Dart inserts the super() call for you automatically — you only write it explicitly when the parent needs arguments or you want a named parent constructor (: super.fromJson(...)).

2. Calling the parent's version of a method

When you override a method but still want the parent's behavior plus something extra, call super.method():

class Animal {
  void describe() => print('I am an animal.');
}

class Dog extends Animal {
  @override
  void describe() {
    super.describe();       // run Animal's version first
    print('Specifically, a dog.');
  }
}

// Dog().describe() prints:
//   I am an animal.
//   Specifically, a dog.

This "extend, don't replace" pattern is everywhere in Flutter — e.g. initState() overrides almost always start with super.initState().


Overriding and @override

Overriding means a subclass provides its own implementation of a method (or getter/setter) it inherited. You signal intent with the @override annotation:

class Animal {
  String sound() => '...';
}

class Cat extends Animal {
  @override
  String sound() => 'Meow';
}

class Cow extends Animal {
  @override
  String sound() => 'Moo';
}

@override is optional, but always use it. It's a safety net: if you misspell the method name or get the signature wrong, the analyzer flags that you're not actually overriding anything — catching a frustrating bug instantly.

The rules of a valid override

A few signature constraints keep overrides type-safe (this is covariance/contravariance, but you don't need the jargon):

  • The return type must be the same as, or a subtype of, the parent's return type. (You can return something more specific.)
  • Parameter types must be the same as, or a supertype of, the parent's. (You can accept something more general — though in practice people keep them identical.)
  • The number of positional parameters must match.

If you violate these, the analyzer complains. We'll see the one deliberate escape hatch — covariant — shortly.


Overriding toString() (the one you'll do constantly)

Every class inherits toString() from Object, but the default is useless — it prints something like Instance of 'Dog'. Override it to make your objects debuggable:

class Dog extends Animal {
  final String breed;
  Dog(super.name, this.breed);

  @override
  String toString() => 'Dog(name: $name, breed: $breed)';
}

void main() {
  print(Dog('Rex', 'Lab')); // Dog(name: Rex, breed: Lab)
}

print and string interpolation call toString() automatically, so this one override makes every log line readable. Do it for any class you'll debug — which is basically all of them.


Polymorphism: one call, many forms

Here's where it gets powerful. Polymorphism ("many forms") means you can treat objects of different subclasses through their common supertype, and each one responds with its own overridden behavior. The decision about which method runs happens at runtime, based on the object's actual type — this is dynamic dispatch.

class Animal {
  String sound() => '...';
}

class Cat extends Animal {
  @override
  String sound() => 'Meow';
}

class Dog extends Animal {
  @override
  String sound() => 'Woof';
}

class Cow extends Animal {
  @override
  String sound() => 'Moo';
}

void main() {
  // A list typed as the SUPERtype, holding different SUBtypes.
  List<Animal> farm = [Cat(), Dog(), Cow()];

  for (final animal in farm) {
    // We call sound() on an `Animal`, but each object runs ITS OWN version.
    print(animal.sound());
  }
  // → Meow / Woof / Moo
}

This is the magic. The loop doesn't know or care whether each animal is a Cat, Dog, or Cow. It just calls sound(), and Dart dispatches to the correct override at runtime. Want to add a Sheep? Write the subclass with its own sound() — the loop never changes. That open-ended extensibility is exactly why polymorphism matters: you write code against the general type, and new specifics slot in without touching it.

Mental model: the variable's type (Animal) decides what methods you're allowed to call (the compile-time contract). The object's real type (Cat) decides which implementation actually runs (the runtime behavior). Polymorphism is the gap between those two — and it's a feature, not a bug.


The covariant keyword

Occasionally you genuinely want an override to narrow a parameter type — accept a more specific type than the parent declared. Normally the override rules forbid this (it can be unsafe). When you're certain it's correct, covariant tells the compiler "trust me, I'll only ever be called with the narrower type":

class Animal {
  void chase(Animal prey) {}
}

class Cat extends Animal {
  @override
  void chase(covariant Mouse prey) { // narrowed from Animal to Mouse
    print('Pouncing on the mouse!');
  }
}

class Mouse extends Animal {}

covariant shifts a check from compile time to runtime — if a Cat is ever asked (through an Animal reference) to chase a non-Mouse, you'll get a runtime error. Use it rarely and deliberately; it's an escape hatch, not an everyday tool.


noSuchMethod — the last-resort hook

When you call a method that doesn't exist on an object, Dart normally throws a NoSuchMethodError. You can intercept that by overriding noSuchMethod:

class Loose {
  @override
  dynamic noSuchMethod(Invocation invocation) {
    print('You called: ${invocation.memberName}');
    return null;
  }
}

This is advanced and niche — it's used to build dynamic proxies, mocks, and flexible wrappers (mocking libraries lean on it heavily). For everyday code you'll rarely write it, but it's good to recognize: it's the hook that catches calls to members that aren't defined.


Putting it together

A small shape hierarchy showing inheritance, super, overriding, toString, and polymorphism all at once:

import 'dart:math';

class Shape {
  final String name;
  Shape(this.name);

  double area() => 0; // default; subclasses override

  @override
  String toString() => '$name with area ${area().toStringAsFixed(2)}';
}

class Circle extends Shape {
  final double radius;
  Circle(this.radius) : super('Circle');

  @override
  double area() => pi * radius * radius;
}

class Square extends Shape {
  final double side;
  Square(this.side) : super('Square');

  @override
  double area() => side * side;
}

void main() {
  final shapes = <Shape>[Circle(2), Square(3)];

  // Polymorphism: each shape computes its own area + toString.
  for (final shape in shapes) {
    print(shape);
  }
  // → Circle with area 12.57
  // → Square with area 9.00

  // Treating them uniformly through the supertype:
  final total = shapes.fold(0.0, (sum, s) => sum + s.area());
  print('Total area: ${total.toStringAsFixed(2)}');
}

Each subclass passes its name up with super(...), overrides area(), and inherits the polymorphic toString() from Shape — which itself calls the overridden area(). Adding a Triangle requires zero changes to main.


Practice Challenges

Challenge 1 — Basic extends. Create a Vehicle with a start() method, and a Car subclass that adds honk(). Show that a Car can do both.

Show solution
class Vehicle {
  void start() => print('Engine on');
}

class Car extends Vehicle {
  void honk() => print('Beep!');
}

void main() {
  var c = Car()
    ..start() // inherited
    ..honk(); // own
}

Car inherits start() for free and adds honk(). (Cascades from Part 4 chain the two calls.)

Challenge 2 — super in constructor. Make Employee extends Person, where Person has a name and Employee adds salary, forwarding name with a super parameter.

Show solution
class Person {
  final String name;
  Person(this.name);
}

class Employee extends Person {
  final double salary;
  Employee(super.name, this.salary);
}

void main() {
  var e = Employee('Sam', 50000);
  print('${e.name}: ${e.salary}'); // Sam: 50000.0
}

super.name forwards the first argument straight to Person(this.name), while this.salary initializes the subclass field.

Challenge 3 — Override + super call. Animal has describe() printing "An animal". Override it in Bird to print the parent's line and then "It can fly".

Show solution
class Animal {
  void describe() => print('An animal');
}

class Bird extends Animal {
  @override
  void describe() {
    super.describe(); // parent first
    print('It can fly');
  }
}

void main() {
  Bird().describe();
  // An animal
  // It can fly
}

super.describe() runs Animal's version, then we add the bird-specific line — the "extend, don't replace" pattern.

Challenge 4 — Polymorphism. Create a List<Shape> with a Circle and a Rectangle, and print each one's area through the common Shape type.

Show solution
abstract class Shape {
  double area();
}

class Circle extends Shape {
  final double r;
  Circle(this.r);
  @override
  double area() => 3.14159 * r * r;
}

class Rectangle extends Shape {
  final double w, h;
  Rectangle(this.w, this.h);
  @override
  double area() => w * h;
}

void main() {
  final shapes = <Shape>[Circle(1), Rectangle(2, 3)];
  for (final s in shapes) {
    print(s.area()); // dispatches to the right override
  }
}

The loop calls area() on a Shape, and each object runs its own override at runtime — the essence of polymorphism. (Here Shape is abstract; Part 6 explains why that's the cleaner design.)

Challenge 5 — toString. Give a Point class a toString() that prints (x, y), and show that print(point) uses it automatically.

Show solution
class Point {
  final int x, y;
  Point(this.x, this.y);

  @override
  String toString() => '($x, $y)';
}

void main() {
  print(Point(3, 4));          // (3, 4)
  print('Located at ${Point(1, 2)}'); // Located at (1, 2)
}

print and string interpolation call toString() for you, so overriding it makes the object self-describing everywhere.

Challenge 6 — Predict the output. What does this print, and what concept explains it?

class A {
  String who() => 'A';
  String greet() => 'Hello from ${who()}';
}

class B extends A {
  @override
  String who() => 'B';
}

void main() {
  print(B().greet());
}
Show answer

It prints Hello from B.

greet() is inherited from A and isn't overridden, so B runs A's greet(). But inside it, who() is dispatched dynamically on the actual object — which is a B — so B's overridden who() runs, yielding B. This is polymorphism in action: even a method defined in the parent calls the child's overrides when invoked on a child instance. (This is also the basis of the "template method" pattern.)


Questions to test yourself

Q1 (basic). What keyword creates a subclass, and what does the subclass inherit?

Show answer

extends. The subclass inherits all of the superclass's accessible members — fields, methods, getters, and setters — and can add new ones or override existing ones.

Q2 (basic). What are the two main uses of super?

Show answer

(1) Calling the superclass constructor from a subclass constructor's initializer list: Dog(name) : super(name). (2) Calling the superclass version of a method you're overriding: super.describe().

Q3 (intermediate). @override is optional. Why use it anyway?

Show answer

It documents intent and acts as a safety net. If you misspell the method name or change the signature so it no longer matches a parent member, the analyzer warns that there's nothing to override — catching a silent bug where you think you're overriding but are actually defining a brand-new, never-called method.

Q4 (intermediate). Explain inheritance vs. composition with a quick example, and how you decide.

Show answer

Inheritance is an "is-a" relationship (Dog is an Animalextends). Composition is a "has-a" relationship (Car has an Engine → store an Engine field). Decide by the relationship: if the subclass truly is a kind of the parent and should be substitutable for it, use inheritance; if it merely uses or contains another object, prefer composition (a field). Composition is more flexible and avoids brittle deep hierarchies.

Q5 (advanced). In polymorphism, what determines which method implementation actually runs — the variable's declared type or the object's runtime type? Why does that matter?

Show answer

The object's runtime type determines the implementation (dynamic dispatch). The variable's declared type only determines which methods you're allowed to call (the compile-time contract). This matters because it's what lets you write code against a general supertype (List<Animal>, calling sound()) and have each concrete object supply its own behavior — and add new subtypes later without changing the calling code.

Q6 (advanced). When would you use covariant, and what trade-off does it make?

Show answer

Use covariant when you deliberately want an override to narrow a parameter type to a subtype (e.g., a Cat.chase that only accepts Mouse instead of any Animal). The trade-off: it relaxes a compile-time safety check, moving it to runtime — if the method is ever called (through the supertype) with an argument that isn't the narrower type, you get a runtime error instead of a compile error. So it's an intentional, sparingly-used escape hatch.


Wrapping up

You've now got the two most powerful pillars:

  • extends models "is-a" and inherits the parent's members; prefer composition for "has-a".
  • super forwards to the parent constructor and calls the parent's method version.
  • @override (always use it) marks intentional overrides; valid overrides may return more specific types and accept more general parameters.
  • Override toString() on basically every class for debuggability.
  • Polymorphism dispatches on the object's runtime type, so code written against a supertype runs each subtype's own behavior — and stays open to new subtypes.
  • covariant narrows a parameter type (runtime-checked); noSuchMethod intercepts missing-member calls (for proxies/mocks).

In Part 6 we make abstraction explicit with abstract classes and interfaces — defining a contract that subclasses must fulfill, and the crucial difference between extends and implements.