Polymorphism in JavaScript: writing flexible code without depending on classes

Polymorphism in JavaScript: writing flexible code without depending on classes

What polymorphism means

Polymorphism means that different pieces of code can respond to the same operation, even if they work differently internally.

The important idea is not “use classes”. The important idea is being able to write a function that works with an expected behavior, without needing to know the exact type of object it received.

For example, if several objects expose a render() method, one function can call it without asking whether the object is a card, an alert, or a modal:

function renderComponent(component) {
  return component.render();
}

That is the core point: the consuming code depends on a capability, not on a specific class.

Why it matters

Without polymorphism, code often fills up with conditionals:

function calculateTotal(paymentMethod, amount) {
  if (paymentMethod.type === 'card') {
    return amount + amount * 0.03;
  }

  if (paymentMethod.type === 'bank_transfer') {
    return amount;
  }

  if (paymentMethod.type === 'crypto') {
    return amount + amount * 0.01;
  }

  throw new Error('Unsupported payment method');
}

This works, but it scales poorly. Every new payment method forces you to modify the same function. That function starts knowing too many details about the system.

With polymorphism, each payment method knows how to calculate its own cost:

const cardPayment = {
  calculateTotal(amount) {
    return amount + amount * 0.03;
  },
};

const bankTransferPayment = {
  calculateTotal(amount) {
    return amount;
  },
};

const cryptoPayment = {
  calculateTotal(amount) {
    return amount + amount * 0.01;
  },
};

function charge(paymentMethod, amount) {
  return paymentMethod.calculateTotal(amount);
}

console.log(charge(cardPayment, 1000)); // 1030
console.log(charge(bankTransferPayment, 1000)); // 1000
console.log(charge(cryptoPayment, 1000)); // 1010

The charge function does not need to know whether the payment is made by card, bank transfer, or crypto. It only needs an object with a calculateTotal method.

Polymorphism with classes

JavaScript supports classes, so we can also represent polymorphism through inheritance:

class PaymentMethod {
  calculateTotal() {
    throw new Error('The calculateTotal method must be implemented');
  }
}

class Card extends PaymentMethod {
  calculateTotal(amount) {
    return amount + amount * 0.03;
  }
}

class BankTransfer extends PaymentMethod {
  calculateTotal(amount) {
    return amount;
  }
}

class Crypto extends PaymentMethod {
  calculateTotal(amount) {
    return amount + amount * 0.01;
  }
}

function charge(paymentMethod, amount) {
  return paymentMethod.calculateTotal(amount);
}

const methods = [new Card(), new BankTransfer(), new Crypto()];

for (const method of methods) {
  console.log(charge(method, 1000));
}

This approach is useful when there is a clear hierarchy. For example: different user types, different storage providers, or different shipping strategies.

The problem appears when inheritance is used only out of habit. In JavaScript, simple objects and functions are often enough.

Duck typing: JavaScript natural style

JavaScript does not require an object to inherit from a specific class before you can use it. If it has the method you need, you can use it.

This is usually called duck typing: if it walks like a duck and sounds like a duck, it can be treated like a duck.

const consoleLogger = {
  log(message) {
    console.log(`[console] ${message}`);
  },
};

const fileLogger = {
  log(message) {
    // Simulated file write
    return `Saved: ${message}`;
  },
};

function recordEvent(logger, event) {
  return logger.log(event);
}

recordEvent(consoleLogger, 'User registered');
recordEvent(fileLogger, 'Payment approved');

The recordEvent function does not ask whether the logger comes from a Logger class. It only needs log to exist.

This pattern appears constantly in modern JavaScript:

  • Components that receive an onClick function.
  • Adapters that expose the same send method.
  • Strategies that implement validate, parse, format, or execute.
  • Mock objects in tests that imitate a real dependency.

Polymorphism with functions

Not all polymorphism needs objects. You can also get it by passing functions with the same signature.

function applyDiscount(price, calculateDiscount) {
  return price - calculateDiscount(price);
}

function vipDiscount(price) {
  return price * 0.2;
}

function blackFridayDiscount(price) {
  return price * 0.35;
}

function noDiscount() {
  return 0;
}

console.log(applyDiscount(100, vipDiscount)); // 80
console.log(applyDiscount(100, blackFridayDiscount)); // 65
console.log(applyDiscount(100, noDiscount)); // 100

The applyDiscount function does not know the business rules. It only receives a compatible strategy.

This style is very common in JavaScript because functions are values. You can pass them around, store them in objects, compose them, and replace them easily.

A more realistic case: validators

Imagine a form where each field has a different rule:

const required = {
  validate(value) {
    return value.trim().length > 0;
  },
  message: 'The field is required',
};

const email = {
  validate(value) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  },
  message: 'The email is not valid',
};

const minLength = {
  validate(value) {
    return value.length >= 8;
  },
  message: 'It must contain at least 8 characters',
};

function validateField(value, validators) {
  return validators.filter((validator) => !validator.validate(value)).map((validator) => validator.message);
}

console.log(validateField('', [required, email]));
console.log(validateField('test@mail.com', [required, email]));
console.log(validateField('123', [required, minLength]));

All validators have the same shape: validate(value) and message. The validateField function can work with any new rule as long as it follows that contract.

This lets you add validators without modifying the main function.

Polymorphism vs conditionals

This does not mean if statements are bad. Sometimes a simple conditional is clearer.

Polymorphism starts to make sense when:

  • There are several variants of the same behavior.
  • Those variants grow over time.
  • Each variant has its own rules.
  • The main function starts filling up with if, switch, or type checks.
  • You want to test each behavior separately.

If you only have two simple cases, an if may be enough. If you have ten cases that change often, separating behaviors is probably better.

Common mistakes

A common mistake is thinking that polymorphism means “create a base class for everything”. In JavaScript, that often creates rigid hierarchies that are hard to maintain.

Another mistake is not defining the contract clearly. If a function expects an object with calculateTotal(amount), every compatible object should respect that signature. If one returns a number, another returns a string, and another throws unexpected errors, polymorphism stops helping.

You should also avoid abstracting too early. Polymorphism is useful when there is real variation. If you do not yet know which parts will change, a premature abstraction can make the code harder to understand.

Conclusion

Polymorphism in JavaScript is about designing code around shared behaviors. You can achieve it with classes, simple objects, or functions.

The practical version of the concept is this: write functions that depend on what something can do, not on the exact class it belongs to.

When used well, it makes code easier to extend, test, and maintain. When used poorly, it only adds unnecessary layers. The key is applying it where behavior actually varies.