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.
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:
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:
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.
@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.
// 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.
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:
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:
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
| Operator | Use When | Behavior on Mismatch |
|---|---|---|
is | Conditional branching, optional handling | Returns false |
is! | Asserting a known type, fail-fast debugging | Throws TypeError |
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:
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!
}
}
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:
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.
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');
}
}
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.
}
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
@overrideenables 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.
issafely checks the runtime type;is!asserts it and throws on mismatch.ascasts explicitly — always guard withisfirst 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.
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.