JavaScript Composition: Building Powerful Applications from Simple Parts
Master the art of composition in JavaScript - learn how to build complex functionality by combining simple, reusable components instead of relying on inheritance.
JavaScript Composition: Building Powerful Applications from Simple Parts
Navigation
- Introduction
- What Is Composition?
- Composition over Inheritance
- Why Choose Composition?
- Best Practices
- Use Case: Building a Game Character System
- Conclusion
Introduction
Imagine you’re building with LEGO blocks. Instead of searching for one massive, pre-built castle piece, you combine smaller, versatile blocks to create something unique.
This is the essence of composition in JavaScript—building complex functionality by combining simple, reusable parts.
While inheritance has dominated object-oriented programming for decades, composition offers a more flexible approach. It aligns perfectly with JavaScript’s dynamic nature and functional programming capabilities.
Whether you’re a beginner writing your first functions or a seasoned developer architecting large applications, understanding composition will fundamentally change how you approach code design.
What Is Composition?
Composition is the practice of combining simple functions or objects to create more complex behavior. Instead of inheriting from a parent class, you compose functionality by mixing and matching smaller, focused components.
Think of it like a recipe: rather than inheriting from a “MasterChef” class, you compose a meal by combining ingredients (functions) that each do one thing well.
This approach follows the single responsibility principle—each function has one job and does it well.
// Simple functions that do one thing
const addSalt = (food) => ({ ...food, seasoning: [...food.seasoning, 'salt'] });
const addPepper = (food) => ({ ...food, seasoning: [...food.seasoning, 'pepper'] });
const cook = (food) => ({ ...food, cooked: true });
// Composition: combining functions to create complex behavior
const prepareSteak = (steak) => cook(addPepper(addSalt(steak)));
Composition over Inheritance
Let’s compare both approaches with a practical example. Imagine we’re building a system for different types of employees:
Inheritance Approach
class Employee {
constructor(name, salary) {
this.name = name;
this.salary = salary;
}
work() {
return `${this.name} is working`;
}
}
class Manager extends Employee {
constructor(name, salary, team) {
super(name, salary);
this.team = team;
}
manage() {
return `${this.name} is managing ${this.team.length} people`;
}
}
class Developer extends Employee {
constructor(name, salary, technologies) {
super(name, salary);
this.technologies = technologies;
}
code() {
return `${this.name} is coding with ${this.technologies.join(', ')}`;
}
}
Composition Approach
// Abilities as composable functions
const canWork = (person) => ({
...person,
work: () => `${person.name} is working`
});
const canManage = (person) => ({
...person,
manage: () => `${person.name} is managing ${person.team.length} people`
});
const canCode = (person) => ({
...person,
code: () => `${person.name} is coding with ${person.technologies.join(', ')}`
});
// Creating employees through composition
const createEmployee = (name, salary) => canWork({ name, salary });
const createManager = (name, salary, team) =>
canManage(canWork({ name, salary, team }));
const createDeveloper = (name, salary, technologies) =>
canCode(canWork({ name, salary, technologies }));
// What about a developer who becomes a manager?
const createTechLead = (name, salary, technologies, team) =>
canManage(canCode(canWork({ name, salary, technologies, team })));
Why Choose Composition?
1. Flexibility
Composition allows you to mix and match behaviors freely. Need a developer who can also manage? No problem. With inheritance, you’d need to create a complex hierarchy or duplicate code.
2. Reusability
Small, focused functions can be reused across different contexts. The canWork
function works for any employee type.
3. Testability
Testing small, pure functions is straightforward. Each piece can be tested in isolation.
4. Avoids Deep Hierarchies
No more “diamond problem” or fragile inheritance chains. Your code stays flat and predictable.
5. Better Alignment with JavaScript
JavaScript’s functional features (closures, higher-order functions) make composition natural and powerful.
6. Easier Debugging
When something breaks, you can isolate the problem to a specific function rather than tracing through complex inheritance hierarchies.
Best Practices
Do’s
✅ Keep functions small and focused
Each function should have a single responsibility. This makes your code easier to understand, test, and debug.
// Good: each function has one responsibility
const withLogging = (obj) => ({
...obj,
log: (message) => console.log(`[${obj.name}]: ${message}`)
});
✅ Use pure functions when possible
Pure functions are predictable and testable. They don’t cause side effects and always return the same output for the same input.
// Good: predictable, testable
const addTimestamp = (data) => ({ ...data, timestamp: Date.now() });
✅ Compose from right to left
This follows mathematical function composition and reads naturally when you understand the pattern.
// Good: reads naturally
const enhancedUser = withAuth(withLogging(createUser('John')));
Don’ts
❌ Don’t mutate the original object
Mutation can lead to unexpected side effects and makes debugging difficult.
// Bad: mutates the original
const badAddFeature = (obj) => {
obj.newFeature = true;
return obj;
};
❌ Don’t create overly complex compositions
Too many layers make code hard to understand and debug. Consider breaking complex compositions into intermediate steps.
// Bad: too many layers, hard to debug
const overEngineered = a(b(c(d(e(f(g(data)))))));
❌ Don’t forget about performance
Deep object spreading can be expensive. Consider using Object.assign
or libraries like Ramda for complex compositions.
// Better for performance with deep objects
const optimized = Object.assign({}, data, newProps);
Use Case: Building a Game Character System
Let’s create a flexible character system for a game where characters can have different abilities:
// Core character creation
const createCharacter = (name, health = 100) => ({
name,
health,
abilities: []
});
// Ability mixins
const canFight = (character) => ({
...character,
abilities: [...character.abilities, 'fight'],
attack: (target) => {
const damage = Math.floor(Math.random() * 20) + 10;
return `${character.name} attacks ${target} for ${damage} damage!`;
}
});
const canCastSpells = (character) => ({
...character,
abilities: [...character.abilities, 'cast spells'],
mana: character.mana || 50,
castSpell: (spell) => {
if (character.mana >= 10) {
return `${character.name} casts ${spell}!`;
}
return `${character.name} doesn't have enough mana!`;
}
});
const canSteal = (character) => ({
...character,
abilities: [...character.abilities, 'steal'],
steal: (target) => `${character.name} attempts to steal from ${target}!`
});
const canHeal = (character) => ({
...character,
abilities: [...character.abilities, 'heal'],
heal: (target) => `${character.name} heals ${target}!`
});
// Creating different character types
const warrior = canFight(createCharacter('Conan', 150));
const mage = canCastSpells(createCharacter('Gandalf'));
const rogue = canSteal(canFight(createCharacter('Robin')));
const paladin = canHeal(canFight(canCastSpells(createCharacter('Arthur', 120))));
// Usage
console.log(warrior.attack('Orc'));
console.log(mage.castSpell('Fireball'));
console.log(rogue.steal('Merchant'));
console.log(paladin.heal('Ally'));
This system is incredibly flexible. Want to add a new ability? Create a new mixin. Need a character with a unique combination of abilities? Just compose them together.
Conclusion
Composition transforms how you think about code organization. Instead of rigid hierarchies, you work with flexible, reusable building blocks.
This approach leads to more maintainable, testable, and adaptable code that’s easier to reason about.
Key Takeaways
- Composition over inheritance provides more flexibility
- Small, focused functions are easier to test and debug
- Pure functions make your code predictable
- JavaScript’s functional features make composition natural
Next Steps
- Start small: Identify repeated patterns in your codebase
- Extract composable functions from complex objects
- Practice with simple examples before tackling complex systems
- Explore functional programming libraries like Ramda or Lodash/FP
Remember, composition isn’t about abandoning all other patterns—it’s about choosing the right tool for each job. Sometimes inheritance makes sense, but more often than not, composition provides the flexibility and clarity your code needs.
Pick a complex object in your current project and try breaking it down into composable pieces. You might be surprised by how much cleaner and more flexible your code becomes.
const journey = compose(
withCuriosity,
withPractice,
withPatience
)(createDeveloper('You'));
journey.begin();