Polymorphism is a fancy word for a simple idea: same interface, different behavior. We call the same method on different objects, and each one responds in its own way. In simple language, polymorphism means “many forms” — the same action can look different depending on who’s doing it.
Languages like Java have strict polymorphism through interfaces and abstract classes. JavaScript takes a more relaxed approach — we get polymorphism through method overriding and duck typing, without needing formal interfaces.
Method overriding (runtime polymorphism)
This is the most common form of polymorphism in JS. A child class overrides a method from the parent, so calling the same method on different instances produces different results:
class Shape {
area() {
return 0;
}
}
class Circle extends Shape {
constructor(radius) { super(); this.radius = radius; }
area() { return Math.PI * this.radius ** 2; }
}
class Rectangle extends Shape {
constructor(w, h) { super(); this.w = w; this.h = h; }
area() { return this.w * this.h; }
}
Now we can write code that works with any shape without caring about the specific type:
const shapes = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach(shape => {
console.log(shape.area()); // each shape calculates its own area
});
// 78.54 (circle)
// 24 (rectangle)
This is the power of polymorphism — the forEach loop doesn’t know or care whether it’s a circle or rectangle. It just calls .area() and trusts each object to do the right thing.
Duck typing
JavaScript doesn’t have interfaces or type checking at runtime (unless we add it ourselves). Instead, it follows a philosophy called duck typing: “If it walks like a duck and quacks like a duck, it’s a duck.”
In simple language, we don’t check what an object is — we check what it can do. If it has the method we need, we call it. Doesn’t matter if it’s a class instance, a plain object, or anything else:
class Duck {
quack() { return "Quack!"; }
}
class Person {
quack() { return "I'm pretending to quack!"; }
}
const fakeQuacker = { quack: () => "Rubber ducky!" };
function makeItQuack(thing) {
// We don't check if thing is a Duck
// We just check if it can quack
if (typeof thing.quack === "function") {
console.log(thing.quack());
}
}
makeItQuack(new Duck()); // "Quack!"
makeItQuack(new Person()); // "I'm pretending to quack!"
makeItQuack(fakeQuacker); // "Rubber ducky!"
All three objects work because they all have a quack() method. That’s duck typing in action.
toString() and valueOf() overriding
Every object in JavaScript inherits toString() and valueOf() from Object.prototype. We can override them to control how our objects behave in string and numeric contexts:
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
toString() {
return `${this.currency} ${this.amount.toFixed(2)}`;
}
valueOf() {
return this.amount;
}
}
const price = new Money(49.99, "USD");
console.log(`Price: ${price}`); // "Price: USD 49.99" — calls toString()
console.log(price + 10); // 59.99 — calls valueOf()
This is polymorphism too — the same operators (+, template literals) produce different results depending on what toString() and valueOf() return.
Symbol.toPrimitive
Symbol.toPrimitive gives us even more control. It’s a method that JavaScript calls when it needs to convert an object to a primitive. It receives a hint — either "string", "number", or "default":
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
[Symbol.toPrimitive](hint) {
if (hint === "string") return `${this.celsius}°C`;
if (hint === "number") return this.celsius;
return this.celsius; // default
}
}
const temp = new Temperature(36.6);
console.log(`Body temp: ${temp}`); // "Body temp: 36.6°C" (string hint)
console.log(temp + 0); // 36.6 (number hint)
When Symbol.toPrimitive is defined, it takes priority over toString() and valueOf().
Symbol.hasInstance
Symbol.hasInstance lets us customize how instanceof works. This is a more advanced form of polymorphism — we’re changing how the type-checking mechanism itself behaves:
class EvenNumber {
static [Symbol.hasInstance](value) {
return typeof value === "number" && value % 2 === 0;
}
}
console.log(4 instanceof EvenNumber); // true
console.log(7 instanceof EvenNumber); // false
console.log("hello" instanceof EvenNumber); // false
We’ve redefined what it means to be an “instance” of EvenNumber. Now instanceof doesn’t check the prototype chain — it runs our custom logic instead.
Ad-hoc polymorphism through operator context
JavaScript operators behave differently depending on the types of their operands. The + operator is the classic example:
console.log(5 + 3); // 8 — numeric addition
console.log("5" + 3); // "53" — string concatenation
console.log(true + true); // 2 — boolean to number coercion
This isn’t something we design — it’s built into the language. But when we override toString(), valueOf(), or Symbol.toPrimitive, we’re plugging into this ad-hoc polymorphism and deciding how our objects behave with these operators.
Practical example: a pluggable logger
Here’s a real-world use of polymorphism. Different loggers implement the same interface, and we can swap them out without changing the code that uses them:
class ConsoleLogger {
log(msg) { console.log(`[CONSOLE] ${msg}`); }
}
class FileLogger {
log(msg) { console.log(`[FILE] Writing: ${msg}`); }
}
class SilentLogger {
log(msg) { /* do nothing */ }
}
function processOrder(order, logger) {
logger.log(`Processing order #${order.id}`);
// ... business logic
logger.log(`Order #${order.id} complete`);
}
// Swap loggers without changing processOrder
processOrder({ id: 42 }, new ConsoleLogger());
processOrder({ id: 43 }, new SilentLogger());
This is polymorphism and duck typing working together. processOrder doesn’t care which logger it gets — it just needs something with a .log() method.
In simple language, polymorphism in JavaScript means we can call the same method on different objects and get different results. We achieve this through method overriding (class hierarchies) and duck typing (if it has the method, just call it). Unlike strict languages, JavaScript doesn’t need interfaces or type declarations — if the method exists, it works.