Polymorphism in JavaScript: Complete Tutorial

A complete guide to polymorphism in JavaScript. Learn prototype-based polymorphism, method overriding for runtime dispatch, duck typing, interface-like patterns, and practical examples of polymorphic design including arrays of mixed types and strategy patterns.

JavaScriptintermediate
13 min read

Polymorphism — "many forms" — is the ability to treat different types uniformly by accessing a common interface. In JavaScript, polymorphism is achieved primarily through the prototype chain and duck typing rather than formal interface contracts. Understanding polymorphism helps write code that is extensible, maintainable, and loosely coupled.

The Core Idea: One Interface, Many Behaviors

The simplest polymorphism arises from method overriding in a class hierarchy:

javascriptjavascript
class Shape {
  area()      { return 0; }
  perimeter() { return 0; }
  describe()  { return `${this.constructor.name}: area=${this.area().toFixed(2)}`; }
}
 
class Circle extends Shape {
  constructor(r) { super(); this.r = r; }
  area()      { return Math.PI * this.r ** 2; }
  perimeter() { return 2 * Math.PI * this.r; }
}
 
class Rectangle extends Shape {
  constructor(w, h) { super(); this.w = w; this.h = h; }
  area()      { return this.w * this.h; }
  perimeter() { return 2 * (this.w + this.h); }
}
 
class Triangle extends Shape {
  constructor(a, b, c) { super(); this.a = a; this.b = b; this.c = c; }
  perimeter() { return this.a + this.b + this.c; }
  area() {
    const s = this.perimeter() / 2;
    return Math.sqrt(s * (s-this.a) * (s-this.b) * (s-this.c));
  }
}
 
// Polymorphic usage — same code works for all Shape types
const shapes = [
  new Circle(5),
  new Rectangle(4, 6),
  new Triangle(3, 4, 5),
];
 
shapes.forEach(s => console.log(s.describe()));
// "Circle: area=78.54"
// "Rectangle: area=24.00"
// "Triangle: area=6.00"
 
const totalArea = shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(totalArea.toFixed(2)); // "108.54"

The forEach and reduce loops work identically regardless of which specific Shape subclass each object is — this is polymorphism in action.

How JavaScript Enables Polymorphism

JavaScript uses the prototype chain for method dispatch. When shape.area() is called:

  1. Look for area on the instance itself → not found
  2. Look on instance.__proto__ (e.g., Circle.prototype) → found! Execute it

For an inherited-but-not-overridden method, lookup continues up the chain to Shape.prototype. This runtime dispatch is what makes polymorphism work:

javascriptjavascript
// Method resolution at runtime based on actual type
const s = new Circle(3);
// s.area() resolves: s → Circle.prototype → found area()
// If Circle didn't override area(), it would continue to Shape.prototype

Refer to the JavaScript prototype chain for the full lookup mechanism.

Duck Typing — Structural Polymorphism

JavaScript does not require formal inheritance for polymorphism. If an object has the right method, it works:

javascriptjavascript
// Three objects with no inheritance relationship — just a common .area() method
const circle = {
  area() { return Math.PI * 5 ** 2; }
};
 
const square = {
  area() { return 10 ** 2; }
};
 
const irregularShape = {
  area() { return 47.3; } // Computed however is appropriate
};
 
// Polymorphic function — only requires .area() method
function totalArea(shapes) {
  return shapes.reduce((sum, s) => sum + s.area(), 0);
}
 
console.log(totalArea([circle, square, irregularShape]).toFixed(2)); // "225.84"

This is duck typing — "if it walks like a duck and quacks like a duck, treat it as a duck." JavaScript function arguments are duck-typed by default.

Interface-Like Patterns

JavaScript has no interface keyword, but you can approximate interfaces:

javascriptjavascript
// "Interface" via symbol + convention
const Drawable = {
  draw: Symbol("draw"),
};
 
const Resizable = {
  resize: Symbol("resize"),
};
 
class Sprite {
  constructor(x, y, width, height) {
    this.x = x; this.y = y;
    this.width = width; this.height = height;
  }
 
  [Drawable.draw](ctx) {
    ctx.fillRect(this.x, this.y, this.width, this.height);
  }
 
  [Resizable.resize](factor) {
    this.width *= factor;
    this.height *= factor;
  }
}
 
// Check for interface compliance
function assertDrawable(obj) {
  if (typeof obj[Drawable.draw] !== "function") {
    throw new TypeError(`${obj.constructor?.name} does not implement Drawable`);
  }
}

More commonly, interface-like behavior uses runtime method checks:

javascriptjavascript
function render(renderable) {
  if (typeof renderable.render !== "function") {
    throw new TypeError("Object must have a render() method");
  }
  renderable.render();
}

Method Overriding With super — Extending Behavior

Polymorphism works with super to compose behaviors:

javascriptjavascript
class Logger {
  log(event) { console.log(`[LOG] ${JSON.stringify(event)}`); }
}
 
class MetricsLogger extends Logger {
  #metrics = { count: 0, events: [] };
 
  log(event) {
    super.log(event); // inherited behavior
    this.#metrics.count++;
    this.#metrics.events.push(event);
  }
 
  getMetrics() { return { ...this.#metrics }; }
}
 
class TimedLogger extends MetricsLogger {
  log(event) {
    super.log({ ...event, timestamp: Date.now() }); // extend with timestamp
  }
}
 
const logger = new TimedLogger();
logger.log({ type: "click", target: "button" });
logger.log({ type: "hover", target: "menu" });
console.log(logger.getMetrics().count); // 2

For details on super method calls, see using the super keyword in JavaScript classes.

The Strategy Pattern — Polymorphism Without Inheritance

Objects with the same method signature can be swapped at runtime — the strategy pattern:

javascriptjavascript
// Sorting strategies — interchangeable objects with a .sort() method
const ascending  = { sort: (arr) => [...arr].sort((a, b) => a - b) };
const descending = { sort: (arr) => [...arr].sort((a, b) => b - a) };
const shuffle    = { sort: (arr) => [...arr].sort(() => Math.random() - 0.5) };
 
class DataList {
  constructor(data, strategy = ascending) {
    this.data = data;
    this.strategy = strategy;
  }
 
  getSorted() {
    return this.strategy.sort(this.data);
  }
 
  setStrategy(strategy) {
    this.strategy = strategy;
  }
}
 
const list = new DataList([5, 2, 8, 1, 9, 3]);
console.log(list.getSorted()); // [1, 2, 3, 5, 8, 9]
 
list.setStrategy(descending);
console.log(list.getSorted()); // [9, 8, 5, 3, 2, 1]

This is duck-typed polymorphism — the DataList class does not care what concrete strategy is provided, only that it has a sort method.

Polymorphism vs Overloading

JavaScript does not have function overloading (different behavior based on argument types/count). Common workarounds:

javascriptjavascript
class Vector {
  constructor(x, y) { this.x = x; this.y = y; }
 
  // Simulate overloading with type checks
  add(other) {
    if (other instanceof Vector) {
      return new Vector(this.x + other.x, this.y + other.y);
    }
    if (typeof other === "number") {
      return new Vector(this.x + other, this.y + other);
    }
    throw new TypeError("Expected Vector or number");
  }
}
 
const v1 = new Vector(1, 2);
const v2 = new Vector(3, 4);
console.log(v1.add(v2)); // Vector { x: 4, y: 6 }
console.log(v1.add(5));  // Vector { x: 6, y: 7 }

Practical Example: Renderer Polymorphism

A real-world pattern — a rendering system that works with multiple output types:

javascriptjavascript
class HtmlRenderer {
  render(component) { return `<div class="${component.type}">${component.content}</div>`; }
}
 
class MarkdownRenderer {
  render(component) {
    if (component.type === "heading") return `# ${component.content}`;
    return component.content;
  }
}
 
class JsonRenderer {
  render(component) { return JSON.stringify(component); }
}
 
// Polymorphic render function
function renderPage(components, renderer) {
  return components.map(c => renderer.render(c)).join("\n");
}
 
const components = [
  { type: "heading", content: "Hello World" },
  { type: "text", content: "Some paragraph text" },
];
 
const html  = renderPage(components, new HtmlRenderer());
const md    = renderPage(components, new MarkdownRenderer());
const json  = renderPage(components, new JsonRenderer());
 
console.log(html);
// <div class="heading">Hello World</div>
// <div class="text">Some paragraph text</div>

The renderPage function is fully polymorphic — it works with any renderer that has a .render(component) method.

Rune AI

Rune AI

Key Insights

  • Prototype chain is the dispatching mechanism: Method calls follow the prototype chain at runtime to find the most specific implementation — this is what makes override-based polymorphism work
  • Duck typing enables structural polymorphism: JavaScript requires no formal inheritance relationship — any object with the right method can be used polymorphically in duck-typed code
  • Method overriding is the primary OOP polymorphism tool: Overriding a parent class method in a subclass causes instances of that subclass to use the child version while still fitting anywhere the parent is accepted
  • Strategy pattern leverages duck typing: Swappable strategy objects with a common method signature give runtime behavioral polymorphism without inheritance hierarchies
  • All JavaScript methods are dynamically dispatched: There are no non-virtual methods — every method call goes through the prototype chain at runtime, making the language inherently polymorphic
RunePowered by Rune AI

Frequently Asked Questions

Is duck typing the same as polymorphism?

Duck typing is a mechanism that enables polymorphism in dynamically typed languages like JavaScript. Classical polymorphism (via class hierarchies) relies on type relationships; duck typing relies on structural compatibility (the presence of required methods/properties). Both achieve the same goal — code that works across multiple types.

When should I use class-based polymorphism vs duck-typed polymorphism?

Use class hierarchies when objects share significant common behavior and you want `instanceof` type checks or shared state via inheritance. Use duck typing (plain objects or unrelated classes) when objects only need to share a method signature but have otherwise different natures. Duck typing is often simpler and more flexible.

Does JavaScript have virtual methods?

ll JavaScript instance methods are effectively virtual — method dispatch always uses the runtime type via the prototype chain. There is no way to force non-virtual dispatch like C++'s non-virtual method calls.

How does polymorphism interact with private class fields?

Private `#` fields are per-class and not inherited. Polymorphism works on the public method interface — if a subclass overrides a public method and calls `super.method()` for the parent's private-field-based implementation, privacy is maintained. See [creating private class fields in modern JS](/tutorials/programming-languages/javascript/creating-private-class-fields-in-modern-js).

Conclusion

Polymorphism in JavaScript is achieved through two complementary mechanisms: prototype-based dispatch (method overriding in class hierarchies) and duck typing (structural compatibility without formal type relationships). The prototype chain enables runtime method resolution — calling shape.area() automatically finds the most specific implementation. Duck typing allows unrelated objects to be used interchangeably as long as they satisfy the expected interface. Together these make JavaScript extremely flexible for polymorphic design, supporting patterns from classic OOP hierarchies to functional strategy patterns and beyond.