Write Flexible, Reusable Code
Polymorphism is a fundamental principle in object-oriented programming that allows objects of different types to be treated through a common interface. The word comes from Greek: "poly" (many) + "morph" (form), meaning "many forms."
In traditional OOP languages like Java or C++, polymorphism is achieved through inheritance and interfaces. JavaScript takes a different approach - it uses duck typing: "If it walks like a duck and quacks like a duck, then it must be a duck." This means JavaScript doesn't care about an object's type or class; it only cares about what methods and properties the object has.
// Different objects with the same interface
const dog = {
name: 'Buddy',
speak() {
return `${this.name} says Woof!`;
}
};
const cat = {
name: 'Whiskers',
speak() {
return `${this.name} says Meow!`;
}
};
const robot = {
name: 'R2D2',
speak() {
return `${this.name} says Beep boop!`;
}
};
// Polymorphic function - works with any object that has a speak() method
function makeItSpeak(animal) {
console.log(animal.speak());
}
makeItSpeak(dog); // Buddy says Woof!
makeItSpeak(cat); // Whiskers says Meow!
makeItSpeak(robot); // R2D2 says Beep boop!
The makeItSpeak function doesn't care what type of object you pass to it. It doesn't
check if the object is a Dog, Cat, or Robot. It only cares that the object has a
speak() method. This is polymorphism in action.
Duck typing is JavaScript's approach to polymorphism. Instead of checking an object's type, you check for the presence of specific methods or properties. This makes JavaScript incredibly flexible but requires careful design.
// A polymorphic function that works with any "drawable" object
function draw(shape) {
if (typeof shape.draw === 'function') {
shape.draw();
} else {
console.error('Object is not drawable');
}
}
const circle = {
radius: 5,
draw() {
console.log(`Drawing a circle with radius ${this.radius}`);
}
};
const square = {
side: 10,
draw() {
console.log(`Drawing a square with side ${this.side}`);
}
};
const triangle = {
base: 8,
height: 6,
draw() {
console.log(`Drawing a triangle with base ${this.base} and height ${this.height}`);
}
};
draw(circle); // Drawing a circle with radius 5
draw(square); // Drawing a square with side 10
draw(triangle); // Drawing a triangle with base 8 and height 6
This pattern is extremely common in JavaScript libraries and frameworks. For example, Promises work
with any object that has a then() method (a "thenable"), and iterators work with any
object that has a next() method.
While duck typing is JavaScript's natural approach, you can also use ES6 classes to implement polymorphism through inheritance and method overriding. This is closer to classical OOP patterns.
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
move() {
return `${this.name} moves`;
}
}
class Dog extends Animal {
speak() {
return `${this.name} barks`;
}
move() {
return `${this.name} runs`;
}
}
class Fish extends Animal {
speak() {
return `${this.name} blubs`;
}
move() {
return `${this.name} swims`;
}
}
class Bird extends Animal {
speak() {
return `${this.name} chirps`;
}
move() {
return `${this.name} flies`;
}
}
// Polymorphic function that works with any Animal
function describeAnimal(animal) {
console.log(animal.speak());
console.log(animal.move());
}
const dog = new Dog('Rex');
const fish = new Fish('Nemo');
const bird = new Bird('Tweety');
describeAnimal(dog); // Rex barks / Rex runs
describeAnimal(fish); // Nemo blubs / Nemo swims
describeAnimal(bird); // Tweety chirps / Tweety flies
Each subclass overrides the speak() and move() methods to provide its own
implementation. The describeAnimal function works with any Animal instance, regardless
of the specific subclass.
While JavaScript doesn't have formal interfaces like TypeScript or Java, you can design your code around implicit interfaces - sets of methods and properties that objects should implement.
// Implicit "Serializable" interface: objects must have toJSON() method
function saveToDatabase(obj) {
if (typeof obj.toJSON !== 'function') {
throw new Error('Object must be serializable (have toJSON method)');
}
const json = obj.toJSON();
console.log('Saving to database:', json);
// ... database logic
}
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
toJSON() {
return {
type: 'user',
name: this.name,
email: this.email
};
}
}
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}
toJSON() {
return {
type: 'product',
name: this.name,
price: this.price
};
}
}
const user = new User('Alice', 'alice@example.com');
const product = new Product('Laptop', 999);
saveToDatabase(user); // Works - User implements toJSON()
saveToDatabase(product); // Works - Product implements toJSON()
This pattern is powerful because it allows you to write functions that work with any object that implements the required interface, without caring about the object's specific type or inheritance hierarchy.
Let's build a practical example: a payment processing system that works with multiple payment methods polymorphically.
// Payment processor that works with any payment method
class PaymentProcessor {
processPayment(paymentMethod, amount) {
if (typeof paymentMethod.pay !== 'function') {
throw new Error('Invalid payment method');
}
console.log(`Processing payment of $${amount}`);
return paymentMethod.pay(amount);
}
}
// Different payment methods implementing the same interface
class CreditCard {
constructor(cardNumber, cvv) {
this.cardNumber = cardNumber;
this.cvv = cvv;
}
pay(amount) {
console.log(`Charging $${amount} to credit card ending in ${this.cardNumber.slice(-4)}`);
return { success: true, method: 'credit_card', amount };
}
}
class PayPal {
constructor(email) {
this.email = email;
}
pay(amount) {
console.log(`Processing $${amount} PayPal payment for ${this.email}`);
return { success: true, method: 'paypal', amount };
}
}
class Cryptocurrency {
constructor(walletAddress) {
this.walletAddress = walletAddress;
}
pay(amount) {
console.log(`Sending $${amount} worth of crypto to ${this.walletAddress}`);
return { success: true, method: 'crypto', amount };
}
}
// Usage
const processor = new PaymentProcessor();
const creditCard = new CreditCard('1234567890123456', '123');
const paypal = new PayPal('user@example.com');
const crypto = new Cryptocurrency('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb');
processor.processPayment(creditCard, 100);
processor.processPayment(paypal, 50);
processor.processPayment(crypto, 200);
The PaymentProcessor doesn't need to know about specific payment method types. It only
needs to know that the payment method has a pay() method. This makes it easy to add new
payment methods without modifying the processor.
JavaScript's built-in array methods are excellent examples of polymorphism. They work with any array-like object or iterable.
// Array.from() works with any iterable
const str = 'hello';
const arr = [1, 2, 3];
const set = new Set([4, 5, 6]);
const map = new Map([['a', 1], ['b', 2]]);
console.log(Array.from(str)); // ['h', 'e', 'l', 'l', 'o']
console.log(Array.from(arr)); // [1, 2, 3]
console.log(Array.from(set)); // [4, 5, 6]
console.log(Array.from(map)); // [['a', 1], ['b', 2]]
// Custom iterable
const range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
} else {
return { done: true };
}
}
};
}
};
console.log(Array.from(range)); // [1, 2, 3, 4, 5]
Array.from() works with any object that implements the iterable protocol (has a
Symbol.iterator method). This is polymorphism - the same function works with different
types of objects.
// Bad - assumes object has the method
function processItem(item) {
item.process(); // What if item doesn't have process()?
}
// Good - check before calling
function processItem(item) {
if (typeof item.process === 'function') {
item.process();
} else {
console.error('Item does not have a process method');
}
}
// Better - use optional chaining (ES2020)
function processItem(item) {
item.process?.();
}
// Bad - unnecessary abstraction
class StringFormatter {
format(str) {
return str;
}
}
class UpperCaseFormatter extends StringFormatter {
format(str) {
return str.toUpperCase();
}
}
// Good - simple functions
const toUpperCase = str => str.toUpperCase();
const toLowerCase = str => str.toLowerCase();
Don't create complex class hierarchies when simple functions will do. JavaScript's functional nature often makes polymorphism through functions more natural than through classes.
// Risky - no validation
function calculateArea(shape) {
return shape.getArea();
}
// Safer - validate the interface
function calculateArea(shape) {
if (!shape || typeof shape.getArea !== 'function') {
throw new TypeError('Shape must have a getArea method');
}
return shape.getArea();
}
Think about what methods objects need to have, not what type they are.
// Good - interface-based design
function render(component) {
if (typeof component.render === 'function') {
return component.render();
}
throw new Error('Component must have a render method');
}
If multiple objects do similar things, give them the same method names.
// Good - consistent naming
const logger = {
log(message) { console.log(message); }
};
const fileLogger = {
log(message) { fs.writeFileSync('log.txt', message); }
};
const remoteLogger = {
log(message) { fetch('/api/log', { body: message }); }
};
Use JSDoc comments to document what methods objects should implement.
/**
* Processes a drawable object
* @param {Object} drawable - Object with a draw() method
* @param {Function} drawable.draw - Method to draw the object
*/
function processDrawable(drawable) {
drawable.draw();
}
For larger codebases, TypeScript's interfaces provide compile-time checking of polymorphic code.
// TypeScript example
interface Drawable {
draw(): void;
}
function render(obj: Drawable) {
obj.draw(); // TypeScript ensures obj has draw()
}
Use polymorphism when:
Avoid polymorphism when: