Skip to main content
โšก Calmops

Object Composition Patterns in JavaScript

Object Composition Patterns in JavaScript

Introduction

Object composition is a design principle that favors composing objects from smaller, reusable pieces rather than using inheritance hierarchies. Composition provides greater flexibility, easier testing, and better code reuse than inheritance. Understanding composition patterns is essential for building scalable, maintainable applications.

In this article, you’ll learn various composition patterns, how to combine objects effectively, and when to use composition over inheritance.

Understanding Composition vs Inheritance

Inheritance Approach

// Inheritance creates rigid hierarchies
class Animal {
  eat() { return 'eating'; }
}

class Flyer {
  fly() { return 'flying'; }
}

// Problem: Can't inherit from both
class Bird extends Animal {
  fly() { return 'flying'; }
}

// Bat needs both but can't inherit from both
class Bat extends Animal {
  fly() { return 'flying'; }
}

Composition Approach

// Composition is more flexible
const canEat = {
  eat() { return 'eating'; }
};

const canFly = {
  fly() { return 'flying'; }
};

const canSwim = {
  swim() { return 'swimming'; }
};

// Combine behaviors as needed
const bird = Object.assign({}, canEat, canFly);
const fish = Object.assign({}, canEat, canSwim);
const duck = Object.assign({}, canEat, canFly, canSwim);

console.log(bird.eat()); // 'eating'
console.log(bird.fly()); // 'flying'
console.log(duck.swim()); // 'swimming'

Basic Composition Patterns

Pattern 1: Object.assign() Composition

// Combine multiple objects
const canEat = {
  eat() { return `${this.name} is eating`; }
};

const canWalk = {
  walk() { return `${this.name} is walking`; }
};

const canTalk = {
  talk() { return `${this.name} is talking`; }
};

function createPerson(name) {
  const person = { name };
  return Object.assign(person, canEat, canWalk, canTalk);
}

const person = createPerson('Alice');
console.log(person.eat()); // 'Alice is eating'
console.log(person.walk()); // 'Alice is walking'
console.log(person.talk()); // 'Alice is talking'

Pattern 2: Spread Operator Composition

// Compose using spread operator
const hasHealth = {
  health: 100,
  takeDamage(amount) {
    this.health -= amount;
  },
  heal(amount) {
    this.health += amount;
  }
};

const hasAttack = {
  attack(target) {
    target.takeDamage(10);
  }
};

const hasDefense = {
  defense: 5,
  defend() {
    return this.defense;
  }
};

function createCharacter(name) {
  return {
    name,
    ...hasHealth,
    ...hasAttack,
    ...hasDefense
  };
}

const character = createCharacter('Warrior');
console.log(character.health); // 100
character.takeDamage(20);
console.log(character.health); // 80

Pattern 3: Mixin Pattern

// Mixin function to add behaviors
function mixin(target, ...sources) {
  return Object.assign(target, ...sources);
}

const canRun = {
  run() { return `${this.name} is running`; }
};

const canJump = {
  jump() { return `${this.name} is jumping`; }
};

const canClimb = {
  climb() { return `${this.name} is climbing`; }
};

class Animal {
  constructor(name) {
    this.name = name;
  }
}

const monkey = new Animal('Monkey');
mixin(monkey, canRun, canJump, canClimb);

console.log(monkey.run()); // 'Monkey is running'
console.log(monkey.climb()); // 'Monkey is climbing'

Pattern 4: Factory Function Composition

// Factory functions for composition
function createLogger() {
  return {
    log(message) {
      console.log(`[LOG] ${message}`);
    },
    error(message) {
      console.error(`[ERROR] ${message}`);
    }
  };
}

function createValidator() {
  return {
    validate(data) {
      return data && data.length > 0;
    }
  };
}

function createUser(name, email) {
  return {
    name,
    email,
    ...createLogger(),
    ...createValidator(),
    
    register() {
      if (this.validate(this.name) && this.validate(this.email)) {
        this.log(`User ${this.name} registered`);
        return true;
      } else {
        this.error('Invalid user data');
        return false;
      }
    }
  };
}

const user = createUser('Alice', '[email protected]');
user.register(); // [LOG] User Alice registered

Advanced Composition Patterns

Pattern 5: Delegation Pattern

// Delegate to composed objects
class Engine {
  start() {
    return 'Engine started';
  }
  
  stop() {
    return 'Engine stopped';
  }
}

class Transmission {
  shift(gear) {
    return `Shifted to ${gear}`;
  }
}

class Car {
  constructor() {
    this.engine = new Engine();
    this.transmission = new Transmission();
  }
  
  start() {
    return this.engine.start();
  }
  
  stop() {
    return this.engine.stop();
  }
  
  drive(gear) {
    return this.transmission.shift(gear);
  }
}

const car = new Car();
console.log(car.start()); // 'Engine started'
console.log(car.drive('D')); // 'Shifted to D'
console.log(car.stop()); // 'Engine stopped'

Pattern 6: Decorator Pattern

// Add functionality to objects
function createBasicUser(name) {
  return {
    name,
    role: 'user'
  };
}

function withAdmin(user) {
  return {
    ...user,
    role: 'admin',
    deleteUser(targetUser) {
      return `Admin ${this.name} deleted ${targetUser.name}`;
    }
  };
}

function withModerator(user) {
  return {
    ...user,
    role: 'moderator',
    banUser(targetUser) {
      return `Moderator ${this.name} banned ${targetUser.name}`;
    }
  };
}

function withPremium(user) {
  return {
    ...user,
    isPremium: true,
    getExclusiveContent() {
      return `${this.name} accessing premium content`;
    }
  };
}

let user = createBasicUser('Alice');
user = withAdmin(user);
user = withPremium(user);

console.log(user.role); // 'admin'
console.log(user.isPremium); // true
console.log(user.deleteUser({ name: 'Bob' })); // 'Admin Alice deleted Bob'
console.log(user.getExclusiveContent()); // 'Alice accessing premium content'

Pattern 7: Trait Pattern

// Traits as reusable behavior sets
const Timestamped = {
  setCreatedAt() {
    this.createdAt = new Date();
  },
  setUpdatedAt() {
    this.updatedAt = new Date();
  }
};

const Validatable = {
  validate() {
    return this.isValid !== false;
  },
  markValid() {
    this.isValid = true;
  },
  markInvalid() {
    this.isValid = false;
  }
};

const Serializable = {
  toJSON() {
    return JSON.stringify(this);
  },
  fromJSON(json) {
    return Object.assign(this, JSON.parse(json));
  }
};

function createDocument(title) {
  return {
    title,
    ...Timestamped,
    ...Validatable,
    ...Serializable
  };
}

const doc = createDocument('My Document');
doc.setCreatedAt();
doc.markValid();
console.log(doc.validate()); // true
console.log(doc.toJSON()); // JSON string

Real-World Examples

Example 1: Plugin System

// Plugin system using composition
class PluginManager {
  constructor() {
    this.plugins = [];
  }
  
  register(plugin) {
    this.plugins.push(plugin);
  }
  
  execute(hookName, data) {
    return this.plugins
      .filter(p => p.hooks && p.hooks[hookName])
      .reduce((result, plugin) => {
        return plugin.hooks[hookName](/programming/result);
      }, data);
  }
}

const authPlugin = {
  name: 'auth',
  hooks: {
    'before:request': (data) => {
      return { ...data, token: 'auth-token' };
    }
  }
};

const loggingPlugin = {
  name: 'logging',
  hooks: {
    'before:request': (data) => {
      console.log('Request:', data);
      return data;
    }
  }
};

const manager = new PluginManager();
manager.register(authPlugin);
manager.register(loggingPlugin);

const result = manager.execute('before:request', { url: '/api/data' });
console.log(result); // { url: '/api/data', token: 'auth-token' }

Example 2: Component System

// UI component composition
const Renderable = {
  render() {
    return `<div>${this.content}</div>`;
  }
};

const Clickable = {
  onClick(callback) {
    this.clickHandler = callback;
  },
  click() {
    if (this.clickHandler) {
      this.clickHandler();
    }
  }
};

const Styleable = {
  setStyle(styles) {
    this.styles = styles;
  },
  getStyleString() {
    return Object.entries(this.styles || {})
      .map(([key, value]) => `${key}: ${value}`)
      .join('; ');
  }
};

function createButton(label) {
  return {
    content: label,
    ...Renderable,
    ...Clickable,
    ...Styleable
  };
}

const button = createButton('Click me');
button.setStyle({ color: 'blue', padding: '10px' });
button.onClick(() => console.log('Button clicked!'));
console.log(button.render()); // <div>Click me</div>
button.click(); // Button clicked!

Example 3: Data Model Composition

// Data model with composed behaviors
const Identifiable = {
  generateId() {
    this.id = Math.random().toString(36).substr(2, 9);
  }
};

const Timestamped = {
  setTimestamps() {
    this.createdAt = new Date();
    this.updatedAt = new Date();
  },
  updateTimestamp() {
    this.updatedAt = new Date();
  }
};

const Validatable = {
  validate() {
    const errors = [];
    if (!this.name) errors.push('Name is required');
    if (!this.email) errors.push('Email is required');
    return errors;
  },
  isValid() {
    return this.validate().length === 0;
  }
};

const Serializable = {
  toJSON() {
    return {
      id: this.id,
      name: this.name,
      email: this.email,
      createdAt: this.createdAt,
      updatedAt: this.updatedAt
    };
  }
};

function createUser(name, email) {
  const user = {
    name,
    email,
    ...Identifiable,
    ...Timestamped,
    ...Validatable,
    ...Serializable
  };
  
  user.generateId();
  user.setTimestamps();
  
  return user;
}

const user = createUser('Alice', '[email protected]');
console.log(user.isValid()); // true
console.log(user.toJSON());

Example 4: State Machine Composition

// State machine using composition
const StateMachine = {
  state: 'idle',
  
  setState(newState) {
    if (this.states && this.states[newState]) {
      this.state = newState;
      if (this.states[newState].onEnter) {
        this.states[newState].onEnter.call(this);
      }
    }
  },
  
  handle(event) {
    const currentState = this.states[this.state];
    if (currentState && currentState.on && currentState.on[event]) {
      const nextState = currentState.on[event];
      this.setState(nextState);
    }
  }
};

function createTrafficLight() {
  return {
    ...StateMachine,
    states: {
      red: {
        onEnter() { console.log('๐Ÿ”ด Red light'); },
        on: { next: 'green' }
      },
      green: {
        onEnter() { console.log('๐ŸŸข Green light'); },
        on: { next: 'yellow' }
      },
      yellow: {
        onEnter() { console.log('๐ŸŸก Yellow light'); },
        on: { next: 'red' }
      }
    }
  };
}

const light = createTrafficLight();
light.setState('red');
light.handle('next'); // ๐ŸŸข Green light
light.handle('next'); // ๐ŸŸก Yellow light
light.handle('next'); // ๐Ÿ”ด Red light

Composition vs Inheritance Decision Tree

// Use composition when:
// 1. Objects have "has-a" relationships
// 2. You need flexible behavior combinations
// 3. You want to avoid deep inheritance hierarchies

// Use inheritance when:
// 1. Objects have "is-a" relationships
// 2. You need polymorphism
// 3. The hierarchy is shallow and stable

// Example: Composition is better here
class Vehicle {
  constructor() {
    this.engine = new Engine();
    this.transmission = new Transmission();
    this.wheels = new Wheels();
  }
}

// Example: Inheritance is appropriate here
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}

Common Mistakes to Avoid

Mistake 1: Over-Composing

// โŒ Wrong - Too many small objects
const obj = {
  ...behavior1,
  ...behavior2,
  ...behavior3,
  ...behavior4,
  ...behavior5
};

// โœ… Better - Group related behaviors
const obj = {
  ...createCoreFeatures(),
  ...createAdvancedFeatures()
};

Mistake 2: Naming Conflicts

// โŒ Wrong - Methods with same name overwrite
const obj = Object.assign(
  {},
  { render() { return 'A'; } },
  { render() { return 'B'; } } // Overwrites first
);

// โœ… Better - Use namespacing or delegation
const obj = {
  rendererA: { render() { return 'A'; } },
  rendererB: { render() { return 'B'; } }
};

Mistake 3: Shared Mutable State

// โŒ Wrong - Shared mutable state
const shared = { items: [] };
const obj1 = { ...shared };
const obj2 = { ...shared };

obj1.items.push('item'); // Affects obj2!

// โœ… Better - Create new instances
function createObject() {
  return { items: [] };
}
const obj1 = createObject();
const obj2 = createObject();

Summary

Object composition provides flexibility and reusability:

  • Composition favors “has-a” relationships
  • Mixins combine multiple behaviors
  • Factory functions create composed objects
  • Delegation delegates to composed objects
  • Traits provide reusable behavior sets
  • Composition is more flexible than inheritance
  • Use composition for complex object structures
  • Avoid deep inheritance hierarchies
  • Combine composition with other patterns

Next Steps

Continue your learning journey:

Comments