JavaScript Composition
Introduction
Composition is a powerful design pattern in object-oriented programming that allows you to build complex objects by combining simpler ones. Unlike inheritance, which creates an "is-a" relationship between objects (e.g., a Car is a Vehicle), composition establishes a "has-a" relationship (e.g., a Car has an Engine).
In JavaScript, composition offers several advantages:
- Increased flexibility and code reusability
- Avoids the limitations of the inheritance hierarchy
- Makes code easier to test and maintain
- Follows the principle "favor composition over inheritance"
This guide will demonstrate how to implement and leverage composition in your JavaScript applications.
Understanding Composition
Composition involves building complex objects by combining simpler, independent objects or functions. Instead of inheriting properties and behaviors, objects are composed of other objects.
Inheritance vs. Composition
Let's first look at an inheritance approach:
class Animal {
constructor(name) {
this.name = name;
}
eat() {
return `${this.name} is eating.`;
}
}
class Bird extends Animal {
fly() {
return `${this.name} is flying.`;
}
}
class Fish extends Animal {
swim() {
return `${this.name} is swimming.`;
}
}
// Usage
const sparrow = new Bird("Sparrow");
console.log(sparrow.eat()); // "Sparrow is eating."
console.log(sparrow.fly()); // "Sparrow is flying."
Now the composition approach:
// Create separate behavior objects
const eater = (state) => ({
eat: () => `${state.name} is eating.`
});
const flyer = (state) => ({
fly: () => `${state.name} is flying.`
});
const swimmer = (state) => ({
swim: () => `${state.name} is swimming.`
});
// Compose objects with selected behaviors
function createBird(name) {
const state = { name };
return {
...state,
...eater(state),
...flyer(state)
};
}
function createFish(name) {
const state = { name };
return {
...state,
...eater(state),
...swimmer(state)
};
}
// Usage
const sparrow = createBird("Sparrow");
console.log(sparrow.eat()); // "Sparrow is eating."
console.log(sparrow.fly()); // "Sparrow is flying."
In the composition example, behaviors are defined as separate functions that can be combined in any way needed, providing greater flexibility.
Implementing Composition in JavaScript
Basic Composition with Object Literals
The simplest form of composition uses object literals to combine properties and methods:
// Define behavior functions
const withName = (name) => ({
name,
setName(newName) { this.name = newName; }
});
const withAge = (age) => ({
age,
setAge(newAge) { this.age = newAge; }
});
const withGreeting = () => ({
greet() { return `Hello, my name is ${this.name}`; }
});
// Compose a person object
const person = {
...withName('Alice'),
...withAge(30),
...withGreeting()
};
console.log(person.greet()); // "Hello, my name is Alice"
person.setAge(31);
console.log(person.age); // 31
Factory Functions for Composition
Factory functions allow us to create objects with composed behaviors:
// Define behavior functions
function hasPosition(x, y) {
return {
position: { x, y },
move(dx, dy) {
this.position.x += dx;
this.position.y += dy;
return this.position;
}
};
}
function hasHealth(hp) {
return {
health: hp,
damage(points) {
this.health -= points;
return this.health;
},
heal(points) {
this.health += points;
return this.health;
}
};
}
function hasName(name) {
return {
name,
sayName() {
return `My name is ${this.name}`;
}
};
}
// Create factory functions to compose objects
function createPlayer(name, x = 0, y = 0, health = 100) {
return {
...hasPosition(x, y),
...hasHealth(health),
...hasName(name),
type: 'player'
};
}
function createEnemy(name, x = 0, y = 0, health = 50) {
return {
...hasPosition(x, y),
...hasHealth(health),
...hasName(name),
type: 'enemy'
};
}
// Usage
const player = createPlayer('Hero', 10, 10);
console.log(player.position); // { x: 10, y: 10 }
console.log(player.move(5, 5)); // { x: 15, y: 15 }
console.log(player.sayName()); // "My name is Hero"
const enemy = createEnemy('Goblin', 20, 20);
console.log(enemy.damage(10)); // 40
console.log(enemy.type); // "enemy"
Practical Applications
Creating a UI Component System
Composition is great for building UI components with different features:
// Base component behaviors
const withElement = (tag) => ({
element: document.createElement(tag),
render() {
return this.element;
}
});
const withInnerText = (text) => ({
setText(newText) {
this.element.innerText = newText;
return this;
},
init() {
this.setText(text);
return this;
}
});
const withStyles = (styles) => ({
setStyles(newStyles) {
Object.assign(this.element.style, newStyles);
return this;
},
init() {
if (this.init) {
const existingInit = this.init;
this.init = function() {
existingInit.call(this);
this.setStyles(styles);
return this;
};
} else {
this.init = function() {
this.setStyles(styles);
return this;
};
}
return this;
}
});
const withEvents = (events) => ({
addEvent(event, callback) {
this.element.addEventListener(event, callback);
return this;
},
init() {
if (this.init) {
const existingInit = this.init;
this.init = function() {
existingInit.call(this);
Object.entries(events).forEach(([event, callback]) => {
this.addEvent(event, callback);
});
return this;
};
} else {
this.init = function() {
Object.entries(events).forEach(([event, callback]) => {
this.addEvent(event, callback);
});
return this;
};
}
return this;
}
});
// Factory functions for different components
function createButton(text, styles = {}, events = {}) {
return {
...withElement('button'),
...withInnerText(text),
...withStyles(styles),
...withEvents(events)
}.init();
}
// Usage (would be used in a browser environment)
const button = createButton(
'Click Me!',
{
padding: '10px',
backgroundColor: 'blue',
color: 'white'
},
{
click: () => alert('Button clicked!')
}
);
// Add to DOM (example, not runnable in this context)
// document.body.appendChild(button.render());
Building a Game Entity System
Composition is very popular in game development to create different types of entities:
// Component functions
const hasPhysics = (mass = 1) => ({
physics: {
mass,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 }
},
applyForce(fx, fy) {
this.physics.acceleration.x += fx / this.physics.mass;
this.physics.acceleration.y += fy / this.physics.mass;
},
update(deltaTime) {
// Apply acceleration
this.physics.velocity.x += this.physics.acceleration.x * deltaTime;
this.physics.velocity.y += this.physics.acceleration.y * deltaTime;
// Apply velocity
this.position.x += this.physics.velocity.x * deltaTime;
this.position.y += this.physics.velocity.y * deltaTime;
// Reset acceleration
this.physics.acceleration = { x: 0, y: 0 };
}
});
const hasCollision = (radius) => ({
collider: {
radius
},
checkCollision(other) {
if (!other.collider) return false;
// Simple circle collision detection
const dx = this.position.x - other.position.x;
const dy = this.position.y - other.position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < (this.collider.radius + other.collider.radius);
}
});
const hasSprite = (imageUrl) => ({
sprite: {
imageUrl,
loaded: false,
image: null
},
loadSprite() {
if (typeof window !== 'undefined') { // Check if running in browser
this.sprite.image = new Image();
this.sprite.image.src = this.sprite.imageUrl;
this.sprite.image.onload = () => {
this.sprite.loaded = true;
};
}
},
draw(context) {
if (this.sprite.loaded && context) {
context.drawImage(
this.sprite.image,
this.position.x - this.collider.radius,
this.position.y - this.collider.radius,
this.collider.radius * 2,
this.collider.radius * 2
);
}
}
});
// Create game entities
function createAsteroid(x, y, radius, imageUrl) {
const asteroid = {
...hasPosition(x, y),
...hasPhysics(radius * 10), // Mass based on size
...hasCollision(radius),
...hasSprite(imageUrl)
};
asteroid.loadSprite();
return asteroid;
}
function createSpaceship(x, y, imageUrl) {
const spaceship = {
...hasPosition(x, y),
...hasPhysics(100),
...hasCollision(20),
...hasSprite(imageUrl),
// Special spaceship methods
thrust(amount) {
// Apply force in the direction the ship is facing
// Simplified for this example
this.applyForce(0, -amount);
},
rotate(angle) {
// Implementation of rotation would go here
}
};
spaceship.loadSprite();
return spaceship;
}
// Usage (conceptual, would be used in a game loop)
const asteroid = createAsteroid(100, 100, 30, 'asteroid.png');
const ship = createSpaceship(200, 200, 'spaceship.png');
// In game loop:
// asteroid.update(deltaTime);
// ship.update(deltaTime);
// if (ship.checkCollision(asteroid)) console.log('Collision detected!');
Benefits of Composition
- Flexibility: Mix and match behaviors without being constrained by inheritance hierarchies
- Code Reuse: Reuse small, focused behavior modules across different types of objects
- Easier Testing: Test individual behaviors in isolation
- Avoiding the "Gorilla/Banana" Problem: With inheritance, you often get the whole jungle when you just want a banana
- Runtime Behavior Changes: Can add or remove behaviors at runtime
When to Use Composition vs. Inheritance
-
Use composition when:
- You need to combine behaviors from different sources
- Behaviors might change at runtime
- You want maximum flexibility and maintainability
-
Use inheritance when:
- There's a clear "is-a" relationship
- The hierarchy is shallow and unlikely to change
- You're extending built-in JavaScript classes or frameworks that expect inheritance
Summary
Object composition offers a powerful alternative to inheritance-based code organization in JavaScript. By building complex objects through the combination of smaller, focused behavior objects, you create more flexible, testable, and maintainable code.
Key points to remember:
- Composition creates "has-a" relationships instead of "is-a" relationships
- Factory functions help create objects with combined behaviors
- Behaviors can be defined as separate, reusable functions
- Composition provides greater flexibility than inheritance
- The spread operator (
...
) makes composition syntax clean and readable
Additional Resources
- Eric Elliott's "Composing Software" series
- You Don't Know JS: Objects & Classes
- Design Patterns: Elements of Reusable Object-Oriented Software
Exercises
- Create a logging system that uses composition to add different logging behaviors (console logging, file logging, etc.) to objects.
- Build a character creation system for a text adventure game using composition for different character abilities.
- Refactor the UI component system to include more behaviors like animation, validation, and form handling.
- Create a data processing pipeline where each step in the pipeline is a composable behavior.
- Compare inheritance and composition approaches by implementing the same system both ways and evaluating the differences.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)