Learning Objectives

  • Understand polymorphism in JavaScript's dynamic context
  • Master duck typing and structural typing
  • Implement polymorphic functions
  • Use method overriding effectively
  • Apply polymorphism to real-world problems

What is Polymorphism?

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 in JavaScript

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.

Polymorphism with Classes

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.

Interface-Based Design

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.

Real-World Example: Payment Processing

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.

Polymorphism with Array Methods

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.

Common Pitfalls

1. Assuming Methods Exist

// 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?.();
}

2. Over-Engineering

// 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.

3. Ignoring Type Checking

// 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();
}

Best Practices

1. Design Around Interfaces

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');
}

2. Use Consistent Method Names

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 }); }
};

3. Document Expected Interfaces

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();
}

4. Consider TypeScript for Large Projects

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()
}

When to Use Polymorphism

Use polymorphism when:

  • You have multiple objects that do similar things in different ways
  • You want to write functions that work with many types
  • You're building plugin systems or extensible architectures
  • You want to reduce code duplication

Avoid polymorphism when:

  • You only have one type of object
  • The objects don't share common behavior
  • Simple conditional logic is clearer
  • It adds unnecessary complexity

Key Takeaways

  • Polymorphism allows different objects to be treated through a common interface
  • JavaScript uses duck typing - objects are defined by their methods, not their types
  • Design around interfaces (sets of methods) rather than specific types
  • Always validate that objects have the methods you need
  • Use consistent method names across similar objects
  • Don't over-engineer - use polymorphism where it adds value
  • Consider TypeScript for compile-time interface checking in large projects