In this article, we will look at what these problems are about and how we can solve them using composition.
the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. - Joe Armstrong, creator of Erlang
Consider the process of creating a hierarchy of role-playing game characters. Initially, two types of characters are required - Warrior and Mage, each of which has a certain amount of health and a name. These properties are public and can be moved to the parent Character class.
class Character {
constructor(name) {
this.name = name;
this.health = 100;
}
}
A warrior can strike, spending his stamina:
class Warrior extends Character {
constructor(name) {
super(name);
this.stamina = 100;
}
fight() {
console.log(`${this.name} takes a mighty swing!`);
this.stamina--;
}
}
And a mage can cast spells that spend some amount of mana:
class Mage extends Character {
constructor(name) {
super(name);
this.mana = 100;
}
cast() {
console.log(`${this.name} casts a fireball!`);
this.mana--;
}
}
Now, let’s introduce a new class, Paladin. A Paladin can both fight and cast spells. How can we solve this? Here are a couple of solutions that share the same lack of elegance:
fight()
and cast()
methods in it from scratch. In this case, the DRY principle is violated because each of the methods will be duplicated upon creation and will need constant synchronization with the methods of the Mage and Fighter classes to track changes.
fight()
and cast()
methods can be implemented at the Character class level so that all three character types have them. This is a slightly better solution, but in this case, the developer must override the fight()
method for the mage and the cast() method for the warrior, replacing them with empty methods or consoling an error.These problems can be solved with a functional approach using composition. It is enough to start not from their types, but from their functions. Basically, we have two key features that determine the abilities of the characters - the ability to fight and the ability to cast spells.
These features can be set using factory functions that extend the state that defines the character:
const canCast = (state) => ({
cast: (spell) => {
console.log(`${state.name} casts ${spell}!`);
state.mana--;
}
})
const canFight = (state) => ({
fight: () => {
console.log(`${state.name} slashes at the foe!`);
state.stamina--;
}
})
Thus, a character is defined by a set of these abilities and initial properties, both general (name and health) and private (stamina and mana):
const fighter = (name) => {
let state = { name, health: 100, stamina: 100 }
return Object.assign(state, canFight(state));
}
const mage = (name) => {
let state = { name, health: 100, mana: 100 }
return Object.assign(state, canCast(state));
}
const paladin = (name) => {
let state = { name, health: 100, mana: 100, stamina: 100 }
return Object.assign(state, canCast(state), canFight(state));
}
With composition, you can avoid inheritance problems, and javascript is the perfect language to do so.