Composition vs Inheritance: OOP Best Practices in JavaScript

Master composition over inheritance in JavaScript with practical examples. Learn when to use each pattern, avoid inheritance pitfalls, and build flexible, maintainable code with real-world scenarios

πŸ“… Published: June 8, 2025 ✏️ Updated: July 1, 2025 By Ojaswi Athghara
#composition #inheritance #web-development #oop #patterns

Composition vs Inheritance: OOP Best Practices in JavaScript

"We Need a Flying Warrior" β€” And My Inheritance Hierarchy Crumbled

I was building a game with different character types. Inheritance seemed perfect:

class Character {
  constructor(name) {
    this.name = name;
    this.health = 100;
  }
  attack() { }
  defend() { }
  swim() { }
  fly() { }
}

class Warrior extends Character {
  attack() { /* sword attack */ }
}

class Wizard extends Character {
  attack() { /* magic attack */ }
}

Then my boss said: "We need a flying warrior and a swimming wizard."

Panic. My inheritance hierarchy couldn't handle it. I needed multiple inheritance, but JavaScript doesn't support that.

My senior developer said: "Favor composition over inheritance. Let me show you."

That refactoring session changed how I design systems. Today, I'll show you composition vs inheritance in JavaScriptβ€”with real examples that'll make it crystal clear.

What Is Inheritance?

Inheritance is an "is-a" relationship where a child class inherits from a parent class.

class Animal {
  constructor(name) {
    this.name = name;
  }
  
  eat() {
    console.log(`${this.name} is eating`);
  }
}

class Dog extends Animal {
  bark() {
    console.log("Woof!");
  }
}

const dog = new Dog("Buddy");
dog.eat();  // Inherited from Animal
dog.bark(); // Dog-specific

Relationship: Dog is-a Animal

What Is Composition?

Composition is a "has-a" relationship where you build complex objects by combining simpler ones.

const canEat = {
  eat() {
    console.log(`${this.name} is eating`);
  }
};

const canBark = {
  bark() {
    console.log("Woof!");
  }
};

function createDog(name) {
  return {
    name,
    ...canEat,
    ...canBark
  };
}

const dog = createDog("Buddy");
dog.eat();  // Composed
dog.bark(); // Composed

Relationship: Dog has eating ability and has barking ability

The Problem with Deep Inheritance Hierarchies

Let's build a realistic example to see where inheritance fails.

❌ Bad Example: Inheritance Hell

class Character {
  constructor(name) {
    this.name = name;
    this.health = 100;
  }
  
  attack() {
    throw new Error("Must implement attack");
  }
  
  swim() {
    console.log(`${this.name} is swimming`);
  }
  
  fly() {
    throw new Error("Can't fly");
  }
}

class Warrior extends Character {
  attack() {
    console.log(`${this.name} attacks with sword!`);
  }
  
  // Warriors can't fly, so fly() throws error (bad!)
}

class Wizard extends Character {
  attack() {
    console.log(`${this.name} casts fireball!`);
  }
  
  fly() {
    console.log(`${this.name} is flying!`);
  }
  
  // Wizards don't swim well, but inherited swim() anyway
}

class FlyingWarrior extends Warrior {
  // Problem: Need to override fly(), but Warrior doesn't have it!
  fly() {
    console.log(`${this.name} is flying!`);
  }
}

// This gets messy quickly!

Problems:

  • ❌ Can't easily mix abilities (flying + warrior)
  • ❌ Forced to implement methods you don't need
  • ❌ Deep hierarchies are hard to maintain
  • ❌ Changes ripple through the hierarchy

βœ… Good Example: Composition Wins

// Abilities as separate objects
const canAttackWithSword = {
  attackWithSword() {
    console.log(`βš”οΈ ${this.name} attacks with sword!`);
  }
};

const canCastSpells = {
  castSpell() {
    console.log(`✨ ${this.name} casts fireball!`);
  }
};

const canSwim = {
  swim() {
    console.log(`🏊 ${this.name} is swimming`);
  }
};

const canFly = {
  fly() {
    console.log(`πŸ¦… ${this.name} is flying!`);
  }
};

// Factory function for creating characters
function createCharacter(name, abilities = []) {
  const character = {
    name,
    health: 100
  };
  
  // Compose abilities
  abilities.forEach(ability => {
    Object.assign(character, ability);
  });
  
  return character;
}

// Create different characters by mixing abilities!
const warrior = createCharacter("Conan", [canAttackWithSword, canSwim]);
const wizard = createCharacter("Gandalf", [canCastSpells, canFly]);
const flyingWarrior = createCharacter("Valkyrie", [canAttackWithSword, canFly]);
const swimmingWizard = createCharacter("Aquarius", [canCastSpells, canSwim]);

// Each has only what they need!
warrior.attackWithSword();  // βš”οΈ Conan attacks with sword!
warrior.swim();             // 🏊 Conan is swimming

wizard.castSpell();         // ✨ Gandalf casts fireball!
wizard.fly();               // πŸ¦… Gandalf is flying!

flyingWarrior.attackWithSword(); // βš”οΈ Valkyrie attacks with sword!
flyingWarrior.fly();             // πŸ¦… Valkyrie is flying!

swimmingWizard.castSpell();      // ✨ Aquarius casts fireball!
swimmingWizard.swim();           // 🏊 Aquarius is swimming

Benefits:

  • βœ… Mix and match abilities easily
  • βœ… No unwanted methods
  • βœ… Flat structure (no deep hierarchy)
  • βœ… Easy to add new abilities

Real-World Example: E-Commerce Products

❌ Problem: Inheritance Approach

class Product {
  constructor(name, price) {
    this.name = name;
    this.price = price;
  }
  
  getInfo() {
    return `${this.name} - $${this.price}`;
  }
  
  ship() {
    throw new Error("Not shippable");
  }
  
  download() {
    throw new Error("Not downloadable");
  }
}

class PhysicalProduct extends Product {
  ship() {
    console.log(`πŸ“¦ Shipping ${this.name}...`);
  }
  // Still has download() that throws error
}

class DigitalProduct extends Product {
  download() {
    console.log(`⬇️ Downloading ${this.name}...`);
  }
  // Still has ship() that throws error
}

class HybridProduct extends Product {
  // Problem: Need both ship and download
  // Can't extend both PhysicalProduct and DigitalProduct!
  ship() {
    console.log(`πŸ“¦ Shipping ${this.name}...`);
  }
  
  download() {
    console.log(`⬇️ Downloading ${this.name}...`);
  }
}

βœ… Solution: Composition Approach

// Behaviors as separate mixins
const shippable = {
  ship() {
    console.log(`πŸ“¦ Shipping ${this.name}...`);
    console.log(`   Address: ${this.address}`);
  },
  
  setShippingAddress(address) {
    this.address = address;
  }
};

const downloadable = {
  download() {
    console.log(`⬇️ Downloading ${this.name}...`);
    console.log(`   Download link: ${this.downloadUrl}`);
  },
  
  setDownloadUrl(url) {
    this.downloadUrl = url;
  }
};

const trackable = {
  track() {
    console.log(`πŸ“ Tracking ${this.name}...`);
    console.log(`   Status: ${this.status}`);
  },
  
  updateStatus(status) {
    this.status = status;
  }
};

// Product factory
function createProduct(name, price, behaviors = []) {
  const product = {
    name,
    price,
    getInfo() {
      return `${this.name} - $${this.price.toFixed(2)}`;
    }
  };
  
  behaviors.forEach(behavior => {
    Object.assign(product, behavior);
  });
  
  return product;
}

// Create different product types
const book = createProduct("JavaScript Book", 29.99, [shippable, trackable]);
const ebook = createProduct("JavaScript eBook", 19.99, [downloadable]);
const course = createProduct("JS Course + Book", 99.99, [downloadable, shippable, trackable]);

// Use them
console.log(book.getInfo());
book.setShippingAddress("123 Main St");
book.updateStatus("In Transit");
book.ship();
book.track();

console.log("\n" + "=".repeat(50) + "\n");

console.log(ebook.getInfo());
ebook.setDownloadUrl("https://example.com/ebook.pdf");
ebook.download();

console.log("\n" + "=".repeat(50) + "\n");

console.log(course.getInfo());
course.setDownloadUrl("https://example.com/course");
course.setShippingAddress("456 Oak Ave");
course.download();
course.ship();
course.track();

Composition Patterns in JavaScript

Pattern 1: Object.assign (Simple Mixins)

const logger = {
  log(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
};

const validator = {
  validate(data) {
    return data !== null && data !== undefined;
  }
};

function createService(name) {
  return Object.assign(
    {
      name,
      process(data) {
        if (this.validate(data)) {
          this.log(`Processing ${data}`);
        }
      }
    },
    logger,
    validator
  );
}

const service = createService("DataService");
service.process("Important Data");
// [2025-10-27T10:30:00.000Z] Processing Important Data

Pattern 2: Factory Functions with Composition

function createTimer() {
  let seconds = 0;
  
  return {
    start() {
      setInterval(() => seconds++, 1000);
    },
    getTime() {
      return seconds;
    }
  };
}

function createLogger() {
  return {
    log(message) {
      console.log(`[LOG] ${message}`);
    }
  };
}

function createTimedLogger() {
  const timer = createTimer();
  const logger = createLogger();
  
  return {
    ...timer,
    ...logger,
    logWithTime(message) {
      this.log(`[${this.getTime()}s] ${message}`);
    }
  };
}

const timedLogger = createTimedLogger();
timedLogger.start();

setTimeout(() => {
  timedLogger.logWithTime("Event occurred");
  // [LOG] [2s] Event occurred
}, 2000);

Pattern 3: Functional Composition

// Pure functions that return new objects
const withId = (obj) => ({
  ...obj,
  id: Math.random().toString(36).substr(2, 9)
});

const withTimestamp = (obj) => ({
  ...obj,
  createdAt: new Date().toISOString()
});

const withLogging = (obj) => ({
  ...obj,
  log(message) {
    console.log(`[${this.id}] ${message}`);
  }
});

// Compose behaviors
function createEntity(data) {
  return withLogging(
    withTimestamp(
      withId({ ...data })
    )
  );
}

const user = createEntity({ name: "Alice", email: "alice@example.com" });
console.log(user);
// {
//   name: "Alice",
//   email: "alice@example.com",
//   id: "abc123xyz",
//   createdAt: "2025-10-27T10:30:00.000Z",
//   log: [Function]
// }

user.log("User created");
// [abc123xyz] User created

When to Use Inheritance vs Composition

βœ… Use Inheritance When:

  1. Clear "is-a" relationship exists
  2. Behavior is truly shared across all subclasses
  3. Hierarchy is shallow (2-3 levels max)
  4. Override behavior is the main goal
// Good use of inheritance
class Error {
  constructor(message) {
    this.message = message;
  }
}

class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.field = field;
  }
}

class DatabaseError extends Error {
  constructor(query, message) {
    super(message);
    this.query = query;
  }
}

βœ… Use Composition When:

  1. "Has-a" relationship is more appropriate
  2. Need multiple behaviors from different sources
  3. Flexibility is important
  4. Want to avoid inheritance hierarchies
// Good use of composition
const persistable = {
  save() { console.log("Saving..."); }
};

const validatable = {
  validate() { console.log("Validating..."); }
};

const cacheable = {
  cache() { console.log("Caching..."); }
};

function createModel(data, behaviors) {
  return Object.assign({ data }, ...behaviors);
}

const user = createModel(
  { name: "Alice" },
  [persistable, validatable, cacheable]
);

Real-World Complete Example: UI Components

Let's build a UI component system using composition:

// Behaviors (mixins)
const clickable = {
  onClick(handler) {
    this.clickHandler = handler;
    console.log(`βœ… Click handler attached to ${this.name}`);
  },
  
  click() {
    if (this.clickHandler) {
      console.log(`πŸ–±οΈ Clicking ${this.name}...`);
      this.clickHandler();
    }
  }
};

const hoverable = {
  onHover(handler) {
    this.hoverHandler = handler;
    console.log(`βœ… Hover handler attached to ${this.name}`);
  },
  
  hover() {
    if (this.hoverHandler) {
      console.log(`πŸ‘† Hovering over ${this.name}...`);
      this.hoverHandler();
    }
  }
};

const draggable = {
  onDrag(handler) {
    this.dragHandler = handler;
    console.log(`βœ… Drag handler attached to ${this.name}`);
  },
  
  drag() {
    if (this.dragHandler) {
      console.log(`βœ‹ Dragging ${this.name}...`);
      this.dragHandler();
    }
  }
};

const styled = {
  setStyle(styles) {
    this.styles = { ...this.styles, ...styles };
    console.log(`🎨 Styles updated for ${this.name}`);
  },
  
  getStyle() {
    return this.styles || {};
  }
};

// Component factory
function createComponent(name, type, behaviors = []) {
  const component = {
    name,
    type,
    render() {
      console.log(`\n${"=".repeat(50)}`);
      console.log(`Rendering ${this.type}: ${this.name}`);
      console.log(`Styles:`, this.getStyle ? this.getStyle() : "None");
      console.log("=".repeat(50));
    }
  };
  
  behaviors.forEach(behavior => {
    Object.assign(component, behavior);
  });
  
  return component;
}

// Create different components
const button = createComponent("Submit Button", "button", [
  clickable,
  hoverable,
  styled
]);

const icon = createComponent("Menu Icon", "icon", [
  clickable,
  styled
]);

const card = createComponent("Product Card", "card", [
  clickable,
  hoverable,
  draggable,
  styled
]);

// Use the components
button.setStyle({ backgroundColor: "blue", color: "white" });
button.onClick(() => console.log("   Form submitted!"));
button.onHover(() => console.log("   Button hovered"));
button.render();
button.hover();
button.click();

console.log("\n");

icon.setStyle({ fontSize: "24px", color: "gray" });
icon.onClick(() => console.log("   Menu opened!"));
icon.render();
icon.click();

console.log("\n");

card.setStyle({ border: "1px solid gray", padding: "20px" });
card.onClick(() => console.log("   Viewing product details"));
card.onHover(() => console.log("   Card highlighted"));
card.onDrag(() => console.log("   Card moved to cart"));
card.render();
card.hover();
card.drag();
card.click();

Composition with Classes (ES6+)

You can also use composition with ES6 classes:

class Storage {
  save(data) {
    console.log("πŸ’Ύ Saving data:", data);
  }
}

class Validator {
  validate(data) {
    console.log("βœ… Validating data:", data);
    return true;
  }
}

class Logger {
  log(message) {
    console.log(`πŸ“ [${new Date().toISOString()}] ${message}`);
  }
}

// Composition using dependency injection
class UserService {
  constructor(storage, validator, logger) {
    this.storage = storage;
    this.validator = validator;
    this.logger = logger;
  }
  
  createUser(userData) {
    this.logger.log("Creating user...");
    
    if (this.validator.validate(userData)) {
      this.storage.save(userData);
      this.logger.log("User created successfully");
      return true;
    }
    
    this.logger.log("User validation failed");
    return false;
  }
}

// Easy to test - can inject mocks!
const userService = new UserService(
  new Storage(),
  new Validator(),
  new Logger()
);

userService.createUser({ name: "Alice", email: "alice@example.com" });

Best Practices

1. Favor Composition Over Inheritance

// ❌ Bad: Deep inheritance
class Vehicle extends MovableObject extends GameObject {}

// βœ… Good: Composition
const vehicle = createGameObject([movable, collidable, renderable]);

2. Keep Mixins Small and Focused

// βœ… Good: Single responsibility per mixin
const loggable = { log() {} };
const serializable = { serialize() {} };
const validatable = { validate() {} };

// ❌ Bad: God mixin
const everything = { 
  log() {}, 
  serialize() {}, 
  validate() {}, 
  save() {}, 
  load() {} 
};

3. Use Descriptive Names

// βœ… Good: Clear intent
const canFly = { fly() {} };
const canSwim = { swim() {} };

// ❌ Bad: Unclear
const ability1 = { action() {} };
const ability2 = { action() {} };

4. Document Your Composition

/**
 * Creates a character with specified abilities
 * @param {string} name - Character name
 * @param {Array} abilities - Array of ability mixins (canFly, canSwim, etc.)
 * @returns {Object} Character object with composed abilities
 */
function createCharacter(name, abilities) {
  // Implementation
}

Conclusion: Composition Is Your Friend

Key Takeaways:

  • βœ… Composition provides flexibility
  • βœ… Inheritance creates tight coupling
  • βœ… Favor composition over inheritance
  • βœ… Use inheritance only for clear "is-a" relationships
  • βœ… Mix behaviors with composition

Remember:

  • Inheritance: "is-a" relationship
  • Composition: "has-a" or "can-do" relationship

Start using composition in your JavaScript projects today. Your code will be more flexible, testable, and maintainable!


Building flexible JavaScript systems with composition? I'd love to hear about your experience! Connect with me on Twitter or LinkedIn!

Support My Work

If this guide helped you understand composition vs inheritance, make better design decisions, or build more maintainable JavaScript applications, I'd really appreciate your support! Creating comprehensive, practical JavaScript tutorials like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for JavaScript developers.

β˜• Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!


Cover image by MichaΕ‚ Parzuchowski on Unsplash

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Β© ojaswiat.com 2025-2027