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

"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:
- Clear "is-a" relationship exists
- Behavior is truly shared across all subclasses
- Hierarchy is shallow (2-3 levels max)
- 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:
- "Has-a" relationship is more appropriate
- Need multiple behaviors from different sources
- Flexibility is important
- 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!