Home
Dart
OOP
Flutter
Contact

OOP: Polymorphism & Runtime Type Checking

Write flexible code that behaves differently for each type — safely and at runtime.

What is Polymorphism

Polymorphism comes from Greek: poly (many) + morphe (form). In programming, it means the same interface, different implementations. You call one method, and each object responds in its own way.

Think about a music player. You press the play button, and it plays — but what plays depends on the media. A song plays audio. A video plays with visuals. A podcast might download first. One button, many behaviors. That's polymorphism in action.

In Dart, polymorphism lets you store a Cat, Dog, and Bird in a single List<Animal>, loop through them, and call makeSound() on each. You don't need to know the exact type — the runtime figures it out and dispatches to the correct implementation.

💡 Why Polymorphism Matters Without polymorphism, you'd write separate code paths for every type: if (animal is Cat) ... else if (animal is Dog) .... Every new type forces you to modify existing code. Polymorphism flips this — you add a new class, implement the same method, and existing code works unchanged.

There are two main forms of polymorphism in Dart:

  • Subtype polymorphism — subclasses override methods from a parent class. The runtime picks the right version based on the object's actual type.
  • Structural (duck) polymorphism — Dart doesn't require a formal interface. If an object has the methods you need, it works, even without a shared base class.

Method Overriding (Runtime Polymorphism)

Runtime polymorphism — also called dynamic dispatch — is the most common form. A subclass overrides a method from its parent, and Dart resolves which implementation to call at runtime based on the object's actual type, not the variable's declared type.

Shape Hierarchy Example

Consider a geometry app. You have different shapes — circles, rectangles, triangles — each with their own area formula. A Shape base class defines the interface, and each subclass provides its own implementation:

shape.dart
abstract class Shape {
  String get name;
  double area();
  String describe() => 'I am a $name with area ${area().toStringAsFixed(2)}';
}

class Circle extends Shape {
  double radius;

  Circle(this.radius);

  @override
  String get name => 'Circle';

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

class Rectangle extends Shape {
  double width;
  double height;

  Rectangle(this.width, this.height);

  @override
  String get name => 'Rectangle';

  @override
  double area() => width * height;
}

class Triangle extends Shape {
  double base;
  double height;

  Triangle(this.base, this.height);

  @override
  String get name => 'Triangle';

  @override
  double area() => 0.5 * base * height;
}

Polymorphic Usage

Now the magic: a single list of Shape objects, and each shape computes its own area when area() is called:

main.dart
void main() {
  var shapes = <Shape>[
    Circle(5),
    Rectangle(4, 7),
    Triangle(6, 3),
  ];

  // Polymorphic loop — each shape uses its own area() implementation
  for (var shape in shapes) {
    print(shape.describe());
  }

  // Calculate total area without knowing specific types
  double total = shapes.fold(0, (sum, s) => sum + s.area());
  print('Total area: ${total.toStringAsFixed(2)}');
}

// Output:
// I am a Circle with area 78.54
// I am a Rectangle with area 28.00
// I am a Triangle with area 9.00
// Total area: 115.54

The for loop doesn't know it's dealing with circles or triangles. It only knows they're Shape objects. Each .area() call dispatches to the correct subclass implementation at runtime. This is the power of dynamic dispatch — you write code against abstractions, not concrete types.

⚠️ Don't Forget @override Always annotate overridden methods with @override. If you mistype a method name, Dart will silently create a new method instead of overriding. The @override annotation makes the analyzer catch these mistakes.

Duck Typing in Dart

Duck typing is a concept from dynamic languages: "If it walks like a duck and quacks like a duck, it must be a duck." You don't check if something IS a duck — you check if it has the methods and properties you need. Dart supports this through its type system.

Unlike languages that require formal interfaces, Dart lets you write functions that work with any object that has the right methods, regardless of inheritance. If a Cat and a Radio both have a play() method, a function that calls .play() works with both — no common base class required.

duck_typing.dart
// No abstract class needed — just any object with a play() method
void startPlayback(dynamic source) {
  print('Starting playback...');
  source.play();
  source.pause();
}

class MusicPlayer {
  String song;

  MusicPlayer(this.song);

  void play() => print('🎵 Playing: $song');
  void pause() => print('⏸ Paused: $song');
}

class VideoPlayer {
  String title;
  int duration;

  VideoPlayer(this.title, this.duration);

  void play() => print('▶️ Playing video: $title ($duration min)');
  void pause() => print('⏸ Paused video: $title');
}

class Podcast {
  String episode;

  Podcast(this.episode);

  void play() => print('🎙️ Episode: $episode');
  void pause() => print('⏸ Paused episode: $episode');
}

void main() {
  startPlayback(MusicPlayer('Bohemian Rhapsody'));
  startPlayback(VideoPlayer('Dart Tutorial', 15));
  startPlayback(Podcast('EP 42: OOP Deep Dive'));
}

// Output:
// Starting playback...
// 🎵 Playing: Bohemian Rhapsody
// ⏸ Paused: Bohemian Rhapsody
// Starting playback...
// ▶️ Playing video: Dart Tutorial (15 min)
// ⏸ Paused video: Dart Tutorial
// Starting playback...
// 🎙️ Episode: EP 42: OOP Deep Dive
// ⏸ Paused episode: EP 42: OOP Deep Dive

Notice there's no shared Playable interface. MusicPlayer, VideoPlayer, and Podcast don't inherit from anything. They just have play() and pause() methods. Dart's duck typing allows startPlayback to work with all three without a formal type hierarchy.

💡 Dart vs. Other Languages Languages like Java require a formal interface to achieve this. Dart's structural approach is more flexible — you can retroactively make unrelated classes work together without modifying their source code. However, Dart's static analyzer prefers typed parameters over dynamic for safety.

Type Checking with is and is!

At runtime, Dart needs to resolve which method to call. The is operator checks whether an object is an instance of a particular type (or any of its subtypes). This is essential when you need to handle different types differently.

Safe Type Checking with is

The is operator returns true if the object matches the type. Use it in if statements to safely branch logic:

type_check.dart
void describeAnimal(dynamic animal) {
  if (animal is Dog) {
    print('It has a tail: ${animal.hasTail}');
    animal.fetch();  // Dart knows it's a Dog — safe to call fetch()
  } else if (animal is Cat) {
    print('It purrs: ${animal.purrs}');
    animal.purr();  // Dart knows it's a Cat
  } else {
    print('Unknown animal type');
  }
}

Assertive Type Checking with is!

The is! operator throws a TypeError if the object doesn't match the type. Use it when you're confident about the type and want to fail fast if wrong:

type_assert.dart
void processOrder(Order order) {
  var payment = order.paymentMethod;
  // We expect this to always be a CreditCard at this point
  var card = payment is! CreditCard;  // Throws TypeError if not a CreditCard
  print('Charging to card ending in ${card.lastFourDigits}');
}

When to Use Each

OperatorUse WhenBehavior on Mismatch
isConditional branching, optional handlingReturns false
is!Asserting a known type, fail-fast debuggingThrows TypeError
⚠️ Avoid Overusing is! is! is not a safety net — it's a crash waiting to happen. If you're not 100% sure of the type, use is with a conditional check or the as cast with a guard. Reserve is! for assertions during development.

Casting with as

Casting converts an object from one type to another. Dart provides the as operator for explicit casts. This is useful when you have a base-type reference and need to access subclass-specific properties or methods.

Safe Casting Pattern

Never cast blindly. Always check the type first with is, then cast:

safe_cast.dart
void processPayment(PaymentMethod method) {
  if (method is CreditCard) {
    var card = method as CreditCard;  // Safe — we just checked
    print('Card type: ${card.network}');
  } else if (method is PayPal) {
    var paypal = method as PayPal;
    print('PayPal email: ${paypal.email}');
  }
}

// Better pattern — combine is check and cast:
void processPayment(PaymentMethod method) {
  if (method is CreditCard) {
    // Dart smart-casts: 'method' is now treated as CreditCard
    print('Card type: ${method.network}');  // No cast needed!
  }
}
🚀 Dart Smart Casts After an is check, Dart automatically narrows the variable's type within the conditional block. You often don't need the explicit as cast — just use the variable directly. This is called type promotion and makes your code safer and cleaner.

When Casts Go Wrong

A wrong cast crashes your app. Always guard with is or use try-catch:

unsafe_cast.dart
var data = fetchData();  // Returns dynamic

// Dangerous — throws if data isn't a String
var text = data as String;

// Safe — check first
if (data is String) {
  print(data.toUpperCase());
}

// Safe — catch the error
try {
  var text = data as String;
} on TypeError {
  print('Data is not a String');
}

Real-World Example: Payment Processing System

Polymorphism shines in real systems. Here's a payment processor that handles credit cards, PayPal, and cryptocurrency — all through a single interface. Adding a new payment method requires zero changes to the existing processing code.

payment.dart
abstract class PaymentMethod {
  String get displayName;
  bool validate();
  void process(double amount);
  void refund(double amount);
}

class CreditCard extends PaymentMethod {
  String number;
  String expiry;
  String cvv;
  String network;  // Visa, Mastercard, etc.

  CreditCard(this.number, this.expiry, this.cvv, this.network);

  String get lastFourDigits => number.substring(number.length - 4);

  @override
  String get displayName => '$network ending in ${lastFourDigits}';

  @override
  bool validate() {
    return number.length == 16 && cvv.length == 3;
  }

  @override
  void process(double amount) {
    print('💳 Charging $network ••••${lastFourDigits}: $${amount.toStringAsFixed(2)}');
  }

  @override
  void refund(double amount) {
    print('💳 Refunding $network ••••${lastFourDigits}: $${amount.toStringAsFixed(2)}');
  }
}

class PayPal extends PaymentMethod {
  String email;

  PayPal(this.email);

  @override
  String get displayName => 'PayPal ($email)';

  @override
  bool validate() => email.contains('@') && email.endsWith('.com');

  @override
  void process(double amount) {
    print('🅿️ PayPal charge to $email: $${amount.toStringAsFixed(2)}');
  }

  @override
  void refund(double amount) {
    print('🅿️ PayPal refund to $email: $${amount.toStringAsFixed(2)}');
  }
}

class CryptoPayment extends PaymentMethod {
  String walletAddress;
  String currency;  // BTC, ETH, etc.

  CryptoPayment(this.walletAddress, this.currency);

  @override
  String get displayName => '$currency wallet';

  @override
  bool validate() => walletAddress.length >= 26;

  @override
  void process(double amount) {
    print('₿ Sent $amount $currency to $walletAddress');
  }

  @override
  void refund(double amount) {
    print('₿ Refunded $amount $currency to $walletAddress');
  }
}
main.dart
void processOrder(String orderId, PaymentMethod method, double amount) {
  if (!method.validate()) {
    print('❌ Invalid payment method: ${method.displayName}');
    return;
  }

  print('Order $orderId — Processing payment');
  method.process(amount);
  print('✅ Payment complete\n');
}

void main() {
  var methods = <PaymentMethod>[
    CreditCard('4111111111111234', '12/25', '123', 'Visa'),
    PayPal('[email protected]'),
    CryptoPayment('0x742d35Cc6634C0532925a3b844Bc9e7595f2bD78', 'ETH'),
  ];

  for (var (i, method) in methods.indexed) {
    processOrder('ORD-${i + 1}', method, 49.99);
  }

  // Adding a new payment method? Just create a class that extends PaymentMethod.
  // processOrder() needs ZERO changes.
}
🚀 Open/Closed Principle in Action This example follows the Open/Closed Principle — the processOrder function is open for extension (add new payment methods) but closed for modification (no changes needed to existing code). That's the real payoff of polymorphism.

Common Pitfalls

1. Overusing is Checks

If you're writing long chains of if (x is TypeA) ... else if (x is TypeB) ..., you're fighting against polymorphism instead of using it. Refactor the common behavior into the base class and override it in subclasses.

2. Ignoring Null Safety with Casts

Casting nullable types without null checks leads to runtime crashes. Always handle nullability:

// Dangerous:
String? value = getValue();
var text = value as String;  // Throws if null!

// Safe:
if (value != null) {
  print(value.toUpperCase());  // Smart-cast to String
}

3. Deep Inheritance vs. Composition

Polymorphism often tempts you to build deep class hierarchies. Resist. Two or three levels is usually enough. Beyond that, prefer composition — embed objects with the behavior you need rather than inheriting it. Mixins are another powerful alternative in Dart for sharing behavior without deep inheritance.

4. Confusing Overloading with Overriding

Dart doesn't support method overloading (same name, different parameters) like Java or C#. If you define two methods with the same name but different signatures, the second one silently replaces the first. Method overriding (redefining a parent method) is the mechanism Dart uses for polymorphism.

Summary

  • Polymorphism means same interface, different implementations — one method call, many behaviors.
  • Method overriding with @override enables runtime dispatch — Dart calls the correct subclass method based on the object's actual type.
  • Duck typing lets you work with any object that has the right methods, without requiring a shared base class.
  • is safely checks the runtime type; is! asserts it and throws on mismatch.
  • as casts explicitly — always guard with is first or use Dart's smart casts.
  • Polymorphism supports the Open/Closed Principle — extend behavior by adding classes, not by modifying existing code.
  • Avoid deep inheritance hierarchies — prefer composition and mixins for complex behavior sharing.
📚 Practice Idea Build a simple Logger system with polymorphic loggers: ConsoleLogger, FileLogger, and NetworkLogger. Each implements log(message) differently. Write a function that accepts any logger and outputs messages through it.
🚀 Next Step Continue to Abstraction & Interfaces.
K
Kesavaraja Murugesan
Flutter Developer & Educator

Flutter developer with 3 years of experience building production-grade mobile applications. Passionate about teaching clean architecture patterns and helping developers write maintainable, scalable code.

← Inheritance Next: Abstraction →