We Need to Talk About Composition Patterns in Object-Oriented JavaScript
At the end of the day, code is code. However, the difference between effective code and the kind that sends developers off to consume passive-aggressive memes is how the code itself is written.
What many tend to forget is that code is a language, which means there are patterns and structures required in order to communicate their meaning. Some patterns and structures are simply better than others.
When it comes to flexibility and modularity, composition patterns are more efficient than inheritance patterns in the long run. Many of us default to inheritance patterns because it’s easy, and most of the time, it is also one of the first things we learn when we start hitting object-oriented patterns.
Before we dive any further, let us begin with what exactly object-oriented is.
The Idea of Objects
The concept of object-oriented goes…
- Everything is an object.
- Objects are written as classes.
- Every class of an object has properties and methods.
And that’s where most courses and tutorials stop. In JavaScript, it sometimes can look something like this:
In the diagram above, the function cat()
acts as a class
with properties
and methods
inside. The variable Tibbers
initializes it as a new
object with the values Tibbers
and 3
for name
and age
. The console.log
calls the newly created Tibbers object
with its associated properties
and methods
.
It’s All About Arrangement
At its core, the idea behind objects is that it describes what something is and what it can do.
What the object is gets captured through properties, and what it can do are captured through methods. The act of initializing a new object creates a unique instance that has its own memory space and values attached.
How these things are arranged, however, is a topic that isn’t often discussed in depth from the onset. Rather, it’s just taught as being the only available methodology for arranging ideas.
If you think of everything in code as an idea, then patterns are the different methodologies for arranging them. The most common pattern taught is the inheritance pattern.
What Is the Inheritance Pattern?
The inheritance pattern puts the object as the centerpiece for creating code. Why wouldn’t we? We’re essentially describing the object through properties and methods for it to be created as instances.
Inheritance runs on the idea that classes can have linear parent and child like relationships, where characteristics that are available in the parent class are also able to be used by the child class (but not the other way around).
When writing patterns, it is essentially the way we group ideas. The language that describes it determines its ultimate structure.
In JavaScript, we use the extends
keyword to associate a parent and child relationship.
class Animal {
constructor(name, age){
this.name = name;
this.age = age;
}
jump(){
console.log(this.name + ' can jump');
}
}
class Cat extends Animal{
constructor(name, age, type){
super(name, age);
this.type = type;
}
catType(){
console.log(this.name + ' is a ' + this.type + ' cat.');
}
}
let tibbers = new Cat('Tibbers', 3, 'Tuxedo');
console.log(tibbers.jump(), tibbers.catType());
The code above contains two classes — one called Animal
and the other called Cat
. The Cat
class uses the extends keyword to associate the relationship and create an inheritance connection between Animal
and Cat
.
As shown on Line 24, the newly initialized object tibbers
is able to access both methods inside the Animal
and the Cat
classes.
The syntax between the above code and the diagram that sits further toward the top is both valid. The usage of classes and constructors is a new ES6 feature that helps with overall readability, comprehension, and code organization.
The Issue With Inheritance
While everything looks fine and dandy for the code above, if we were to add more methods and different animals — such as Dog, Gerbil and Unicorn — we might run into some problems for scalability.
In the above diagram, makeNoise()
and color()
are repeated (with the assumption that they both do exactly the same functionality) and the Unicorn
class doesn’t really need a vetStatus()
method. What happens if you want to make the method tapDance()
to Cat
but it’s a child of the sibling Rodent
?
The main issue with an inheritance pattern is that the methods are coupled to the class it sits in. The potential for repetition and code fragmentation starts to occur once the codebase gets to a certain size. Code complexity starts to become intrinsic to code growth and is a major reason why many advocate for a composition pattern.
How to Compose a Composition
The idea of composition pattern turns the inheritance way of constructing classes on its head. Rather than have methods inside the class, they sit outside and are referenced as needed by the class during its construction.
Imagine all your methods sitting on a table. You have a class that references everything that it needs. Your boss rocks up and tells you that it also needs an extra feature that already exists as a method. All you have to do is reference it, and therefore, composing the class on the fly with only the things you truly need.
In the above example, all the methods are decoupled and taken out of the class. They are then referenced to make up the final composition of accessible methods for the class.
Under the inheritance pattern, you get the parent methods, in addition to your current methods. It gets messy if you try to cross share a method from another class that might be the child of your sibling, where you end with the entire chain of methods that you don’t need.
In JavaScript, you use Object.assign()
in order to create a composition of features for your object class.
const tapDancer = (state) => ({
tapDance(duration){
console.log(state.name + ' has tap danced for ' + duration + ' seconds');
}
})
const vetVistor = (state) => ({
visitVet(status){
console.log(state.name + ' is here because they have a/an' + status);
state.vetCount += 1;
}
})
function Cat(name, breed, vetCount){
let cat = {name, breed, vetCount};
return Object.assign(
cat,
tapDancer(cat),
vetVistor(cat)
);
}
function Unicorn(name, location){
let unicorn = {name, location};
return Object.assign(
unicorn,
tapDancer(unicorn)
);
}
const tibbers = Cat('Tibbers', 'tuxedo', 3);
const edward = Unicorn('Ed', 'England');
tibbers.visitVet('upset stomach');
edward.tapDance(2341);
In the code above, methods are set as const
with state
as an injection. These methods are not physically located inside anything else. The functions Cat
and Unicorn
are created with a let
to set up the injected initializing properties. These objects are then used as states to be passed into the method functions.
In the case of Cat
, tapDancer()
and vetVisitor()
are referenced as part of the class’ composition. In Unicorn
, only tapDancer()
is used. Now, imagine if both had more features and functionality that may or may not overlap, or if there are more classes of animals to be added, you simply just have to initialize the state and then reference the necessary methods to construct the shape of your object.
Everything Is a Pattern
Everything in code essentially boils down to patterns, and not all patterns are made equal.
Patterns exist to solve a problem, and over time, they can evolve in the same way the composition pattern is the evolution of the inheritance pattern. However, sometimes the community and or the teaching materials haven’t quite caught up. Or perhaps the material is simply dated or the learning developer isn’t aware of such a thing.
There’s a myriad of reasons why certain patterns still exist in common usage, despite having better counterparts. It takes active community discussion of the topic to help increase the overall standard of code currently in the wild.