Loading content...
We've established that polymorphism means 'many forms.' Ironically, polymorphism itself takes many forms. The term encompasses several distinct mechanisms, each with different characteristics, use cases, and trade-offs.
Understanding these types is crucial because:
This page provides a complete taxonomy of polymorphism types, giving you the vocabulary and understanding to select and apply the right approach for any situation.
By the end of this page, you will understand the four major types of polymorphism (subtype, parametric, ad-hoc, and coercion), when each is appropriate, how they're implemented in common languages, and how they relate to each other.
Computer scientists have identified four primary types of polymorphism. This classification, formalized by Christopher Strachey and expanded by Luca Cardelli and Peter Wegner, remains the standard framework:
| Type | Also Known As | Core Mechanism | When Resolved |
|---|---|---|---|
| Subtype | Inclusion, Runtime | Inheritance/Interfaces | Runtime |
| Parametric | Generic, Universal | Type Parameters | Compile-time |
| Ad-hoc | Overloading | Multiple signatures | Compile-time |
| Coercion | Casting, Conversion | Type transformation | Compile/Runtime |
These types can be grouped into two broader categories:
This taxonomy emerged from type theory research in the 1960s-80s. While academic in origin, it has profound practical implications. Understanding these categories helps you recognize patterns across different programming languages and paradigms.
Let's explore each type in depth, starting with the most commonly discussed in object-oriented programming: subtype polymorphism.
Subtype polymorphism is what most developers mean when they say 'polymorphism' in an OOP context. It enables treating objects of derived types as objects of their base type.
Definition:
Subtype polymorphism allows a reference of a supertype to hold objects of any subtype, with method calls resolved based on the actual object's type at runtime.
The Classic Example:
class Animal {
speak() { return 'Some sound'; }
}
class Dog extends Animal {
speak() { return 'Bark!'; }
}
class Cat extends Animal {
speak() { return 'Meow!'; }
}
// The polymorphic usage
function makeSound(animal: Animal) {
console.log(animal.speak()); // Which speak()? Depends on actual object
}
makeSound(new Dog()); // Output: 'Bark!'
makeSound(new Cat()); // Output: 'Meow!'
The function makeSound accepts any Animal. At runtime, when speak() is called, the actual object's type determines which implementation runs. This is dynamic dispatch or late binding.
| Characteristic | Description |
|---|---|
| Mechanism | Inheritance (class extension) or interface implementation |
| Resolution Time | Runtime (dynamic dispatch) |
| Type Relationship | IS-A relationship required (Dog IS-A Animal) |
| Method Lookup | Virtual method table (vtable) in most languages |
| Flexibility | New subtypes can be added without changing code |
| Cost | Slight runtime overhead for dispatch lookup |
Parametric polymorphism enables writing code that works uniformly across types, specified as type parameters. This is what languages like Java, C#, and TypeScript call 'generics.'
Definition:
Parametric polymorphism allows functions and types to be written generically so they can handle values identically without depending on their type, with type parameters filled in at compile time.
The Generic Example:
// A function that works with ANY type T
function first<T>(array: T[]): T | undefined {
return array.length > 0 ? array[0] : undefined;
}
first([1, 2, 3]); // T is number, returns 1
first(['a', 'b', 'c']); // T is string, returns 'a'
first([{id: 1}, {id: 2}]); // T is object, returns {id: 1}
// A generic data structure
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const numberStack = new Stack<number>();
const stringStack = new Stack<string>();
The code doesn't change based on what type is used—it's parametric in the type. The type becomes a parameter, filled in when the code is used.
| Characteristic | Description |
|---|---|
| Mechanism | Type parameters that are substituted at compile time |
| Resolution Time | Compile-time (static) |
| Type Relationship | None required (works with any type) |
| Code Generation | Single implementation or monomorphized copies |
| Type Safety | Full compile-time type checking preserved |
| Cost | No runtime overhead (resolved at compile time) |
Generics can be constrained with bounds: function sort<T extends Comparable>(items: T[]) requires T to implement Comparable. This combines parametric and subtype polymorphism—the function works with any type that satisfies the bound.
Key Distinction from Subtype Polymorphism:
With subtype polymorphism, you write code for a specific base type (Animal), and it works with subtypes (Dog, Cat). With parametric polymorphism, you write code that truly doesn't care about type—it works the same way for any type. The first function treats numbers, strings, and objects identically.
Ad-hoc polymorphism enables the same function name to have multiple implementations for different types. The compiler selects which implementation to use based on argument types. This is commonly known as method overloading or operator overloading.
Definition:
Ad-hoc polymorphism allows functions with the same name to exhibit different behavior depending on the types of arguments passed, with the correct implementation selected at compile time.
The Overloading Example:
// Function overloading - same name, different signatures
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: any, b: any): any {
return a + b;
}
add(1, 2); // Uses number version, returns 3
add('a', 'b'); // Uses string version, returns 'ab'
// Operator overloading (in languages that support it)
class Vector {
constructor(public x: number, public y: number) {}
// In C++/Python, you could overload +
// operator+(other: Vector): Vector {
// return new Vector(this.x + other.x, this.y + other.y);
// }
}
The 'ad-hoc' name comes from the fact that each implementation is specific to certain types—you must explicitly define behavior for each type combination you want to support.
| Characteristic | Description |
|---|---|
| Mechanism | Multiple function definitions with same name, different types |
| Resolution Time | Compile-time (static dispatch) |
| Type Relationship | None required—each overload is independent |
| Implementation | Separate code for each overloaded variant |
| Extensibility | Limited—must modify source to add overloads |
| Cost | No runtime overhead (compile-time selection) |
Don't confuse overloading (ad-hoc polymorphism) with overriding (subtype polymorphism). Overloading: same name, different signatures, compile-time selection. Overriding: same signature, different classes, runtime selection. They're fundamentally different mechanisms.
Common Uses of Ad-hoc Polymorphism:
+ work for vectors, matrices, complex numbersprint(int), print(string), print(object)Coercion polymorphism enables values of one type to be automatically or explicitly converted to another type, allowing them to be used where the target type is expected.
Definition:
Coercion polymorphism allows an argument to be converted to the type expected by a function, either implicitly (automatic coercion) or explicitly (casting), enabling code written for one type to work with compatible types.
Coercion Examples:
// Implicit coercion (automatic)
function processNumber(n: number) {
console.log(n * 2);
}
const integer = 5;
processNumber(integer); // int coerced to number (widening)
// In weakly-typed languages like JavaScript:
'5' + 3; // '53' (number coerced to string for concatenation)
'5' * 3; // 15 (string coerced to number for multiplication)
// Explicit coercion (casting)
const value: unknown = 'hello';
const str = value as string; // Explicit cast
// C-style casting
// double result = (double)intValue / otherInt;
| Type | Description | Safety |
|---|---|---|
| Widening | Convert to larger/more general type (int → float) | Safe, no data loss |
| Narrowing | Convert to smaller/more specific type (float → int) | Potentially unsafe, may lose data |
| Implicit | Compiler performs automatically | Usually widening only |
| Explicit | Programmer requests via cast syntax | Any conversion, programmer responsible |
Implicit coercion can hide bugs. JavaScript's '5' + 3 = '53' vs '5' * 3 = 15 is notorious. Strongly-typed languages limit implicit coercion to safe widening conversions. Explicit casting bypasses type safety—use with caution.
Coercion's Role in Polymorphism:
Coercion is sometimes considered 'weak' polymorphism because it doesn't truly enable working with multiple types—it converts everything to a single type. However, it does enable code written for one type to accept values of compatible types, which is a form of polymorphic capability.
The key distinction:
Understanding when to use each polymorphism type is essential for effective design. Here's a comprehensive comparison:
| Criterion | Subtype | Parametric | Ad-hoc | Coercion |
|---|---|---|---|---|
| Resolution | Runtime | Compile-time | Compile-time | Compile/Runtime |
| Implementation | Single per type | Single for all | Multiple | Conversion rules |
| Extensibility | High (new subtypes) | High (new type params) | Low (modify source) | Limited |
| Type Safety | Compile-time | Compile-time | Compile-time | Variable |
| Overhead | Small (vtable) | None | None | None/Small |
| Use Case | Behavior variation | Type-agnostic logic | Same name, diff types | Type compatibility |
Decision Framework:
| When You Need... | Use... | Example |
|---|---|---|
| Different behavior based on type | Subtype | Shape.draw() varies by shape |
| Same logic for any type | Parametric | List<T> works same for all T |
| Same name, type-specific logic | Ad-hoc | print(int) vs print(string) |
| Type compatibility | Coercion | int → float automatic |
| Both extensibility AND type-safe logic | Bounded generics | sort<T extends Comparable> |
These types aren't mutually exclusive—they combine powerfully. A generic collection (parametric) might contain elements accessed polymorphically (subtype). Method overloads (ad-hoc) might internally use generics. Master each type, then learn to compose them.
Different languages support these polymorphism types to varying degrees. Understanding your language's capabilities helps you make the most of available tools:
| Language | Subtype | Parametric | Ad-hoc | Notes |
|---|---|---|---|---|
| Java | ✓ Full | ✓ Generics (type erasure) | ✓ Overloading | Subtype via classes/interfaces |
| C# | ✓ Full | ✓ Generics (reified) | ✓ Overloading + operators | Richer than Java generics |
| C++ | ✓ Full | ✓ Templates | ✓ Overloading + operators | Templates = powerful but complex |
| Python | ✓ Duck typing | ✓ Via duck typing | ✓ Limited | Dynamic typing changes picture |
| TypeScript | ✓ Structural | ✓ Generics | ✓ Overloading | Structural subtyping |
| Go | ✓ Interfaces | ✓ Generics (1.18+) | ✗ None | No classes, interface-based |
| Rust | ✓ Traits | ✓ Generics | ✓ Traits | Traits unify subtype/ad-hoc |
Dynamic languages like Python use 'duck typing'—if it walks like a duck and quacks like a duck, it's a duck. No formal inheritance needed. TypeScript uses 'structural subtyping'—types are compatible if their structures match, regardless of declared relationships. These are alternative approaches to achieving polymorphic flexibility.
Type Erasure vs Reification:
An important distinction in parametric polymorphism:
Type Erasure (Java): Generic type info is removed at compile time. List<String> becomes List at runtime. Simpler but limits reflection.
Reification (C#, C++): Generic type info preserved at runtime. List<string> is distinct from List<int>. More powerful but potentially larger code.
This affects what you can do with generics—e.g., in Java you can't write new T() inside a generic because T is erased.
We've explored the four major types of polymorphism. Here's the consolidated reference:
What's Next:
With a complete understanding of polymorphism types, we're ready to explore the most important question: Why does polymorphism matter? The next page examines the practical impact of polymorphism on real-world software development—maintainability, extensibility, testing, and architectural elegance.
You now have a complete taxonomy of polymorphism types—subtype, parametric, ad-hoc, and coercion. This vocabulary will serve you in design discussions, code reviews, and architectural decisions. Understanding which type of polymorphism to apply is as important as understanding polymorphism itself.