How To Do Object Oriented Programming The Right Way
Object Oriented Programming (OOP) is a software design pattern that allows you to think about problems in terms of objects and their interactions. OOP is typically done with classes or with prototypes. Most languages that implement OOP (e.g., Java, C++, Ruby, Python) use class-based inheritance. JavaScript implements OOP via Prototypal inheritance. In this article, I’m going to show you how to use both approaches for OOP in JavaScript, discuss the advantages and disadvantages of the two approaches of OOP and introduce an alternative for OOP for designing more modular and scalable applications.
This article was also published on Medium.
Table of Contents
Primer: What is an Object?
OOP is concerned with composing objects that manages simple tasks to create complex computer programs. An object consists of private mutable states and functions (called methods) that operate on these mutable states. Objects have a notion of self and reused behavior inherited from a blueprint (classical inheritance) or other objects (prototypal inheritance).
Inheritance is the ability to say that these objects are just like that other set of objects except for these changes. The goal of inheritance is to speed up development by promoting code reuse.
Classical Inheritance
In classical OOP, classes are blueprints for objects. Objects are created or instantiated from classes. There’s a constructor that is used to create an instance of the class with custom properties.
Consider the following example:
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
getFullName() {
return this.firstName + " " + this.lastName;
}
}
The class
keyword from ES6 is used to create the Person
class with properties stored in this
called firstName
and lastName
, which are set in the constructor
and accessed in the getFullName
method.
We instantiate an object called person
from the Person
class with the new
key word as follows:
let person = new Person("Dan", "Abramov");
person.getFullName(); //> "Dan Abramov"
// We can use an accessor function or access directly
person.firstName; //> "Dan"
person.lastName; //> "Abramov"
Objects created using the new
keyword are mutable. In other words, changes to a class affect all objects created from that class and all derived classes which extends from the class.
To extend a class, we can create another class. Let’s extend the Person
class to make a User
. A User
is a Person with an email and a password.
class User extends Person {
constructor(firstName, lastName, email, password) {
super(firstName, lastName);
this.email = email;
this.password = password;
}
getEmail() {
return this.email;
}
getPassword() {
return this.password;
}
}
In the code above, we created a User
class which extends the capability of the Person
class by adding email and password properties and accessor functions. In the App
function below, a user
object is instantiated from the User
class:
function App() {
let user = new User("Dan", "Abramov", "dan@abramov.com", "iLuvES6");
user.getFullName(); //> "Dan Abramov"
user.getEmail(); //> "dan@abramov.com"
user.getPassword(); //> "iLuvES6"
user.firstName; //> "Dan"
user.lastName; //> "Abramov"
user.email; //> "dan@abramov.com"
user.password; //> "iLuvES6"
}
That seems to work just fine but there’s a big design flaw with using the classical inheritance approach: How the heck do the users of the User
class (e.g., App
) know User
comes with firstName and lastName and there’s a function called getFullName
that can be used? Looking at the code for User
class does not tell us anything about the data or methods from its super class. We have to dig into the documentation or trace code through the class hierarchy.
As Dan Abramov puts it:
The problem with inheritance is that the descendants have too much access to the implementation details of every base class in the hierarchy, and vice versa. When the requirements change, refactoring a class hierarchy is so hard that it turns into a WTF sandwich with traces of outdated requirements.
Classical inheritance is based on establishing relationships through dependencies. The base class (or super class) sets the baseline for the derived classes. Classical inheritance is OK for small and simple applications that don’t often change and has no more than one level of inheritance (keeping our inheritance trees shallow to avoid The Fragile Base Class problem) or wildly different use cases. Class-based inheritance can become unmaintainable as the class hierarchy expands.
Eric Elliot described how classical inheritance can potentially lead to project failure, and in the worst cases, company failures:
Get enough clients using
new
, and you can’t back out of the constructor implementation even if you want to, because code you don’t own will break if you try.
When many derived classes with wildly different use cases are created from the same base class, any seemingly benign change to the base class could cause the derived classes to malfunction. At the cost of increased complexity to your code and the entire software creation process, you could try to mitigate side effects by creating a dependency injection container to provide an uniform service instantiation interface by abstracting the instantiation details. Is there a better way?
Prototypal Inheritance
Prototypal inheritance does not use classes at all. Instead, objects are created from other objects. We start with a generalized object we called a prototype. We can use the prototype to create other objects by cloning it or extend it with custom features.
Although in the previous section, we showed how to use the ES6 class
, JavaScript classes are not classy.
typeof Person; //> "function"
typeof User; //> "function"
ES6 classes are actually syntactic sugar of JavaScript’s existing prototypal inheritance. Under the hood, creating a class with a new
keyword creates a function object with code from the constructor
.
JavaScript is fundamentally a prototype-oriented language.
The simple types of JavaScript are numbers, strings, booleans (true and false), null, and undefined. All other values are objects. Numbers, strings, and booleans are object-like in that they have methods, but they are immutable. Objects in JavaScript are mutable keyed collections. In JavaScript, arrays are objects, functions are objects, regular expressions are objects, and, of course, objects are objects.
Let’s look at one of these objects that JavaScript gives us for free out-of-the-box: the Array
.
Array instances inherit from Array.prototype which includes many methods which are categorized as accessors (do not modify the original array), mutators (modifies the original array), and iterators (applies the function passed in as an argument onto every element in the array to create a new array).
Accessors:
Array.prototype.includes(e)
- returns true if elemente
is included in the array. False otherwise.Array.prototype.slice(i,j)
- extract array from indexi
to indexj
(exclusive). Return as new array.
Mutators:
Array.prototype.push(e)
- adde
to the tailArray.prototype.pop(e)
- removee
from the tailArray.prototype.splice(i, j)
- extract array from indexi
to indexj
(exclusive). Discard the rest.
Mutator functions modify the original array. splice
gives you the same sub-array as slice
but you want to maintain the original array, slice
is a better choice.
Iterators:
Array.prototype.map(f)
- applies the functionf
onto every element of the given array to compute the new elements of the resultant array.Array.prototype.filter(f)
- evaluates every element of the given array against a predicatef
and returns it with the resultant array if it passesf
.Array.prototype.forEach(f)
- applies the functionf
onto every element of the given array.
map
and forEach
are similar in that they are doing something to everything to the array but the key difference is map
returns an array while forEach
is like a void function and returns nothing. Good functional software design practices say we should always write functions that has no side effects, i.e., don’t use void functions. forEach
doesn’t do anything to the original array so map
is a better choice if you want to do any data transformation. One potential use case of forEach
is printing to console for debugging:
let arr = [1, 2, 3];
arr.forEach((e) => console.log(e));
arr; //> [1,2,3]
Suppose we want to extend the Array
prototype by introducing a new method called partition
, which divides the array into two arrays based on a predicate. For example [1,2,3,4,5] becomes [[1,2,3], [4,5]] if the predicate is “less than or equal to 3”. Let’s write some code to add partition
to the Array prototype:
Array.prototype.partition = function (pred) {
let passed = [];
let failed = [];
for (let i = 0; i < this.length; i++) {
if (pred(this[i])) {
passed.push(this[i]);
} else {
failed.push(this[i]);
}
}
return [passed, failed];
};
Now we can use partition
on any array:
[1,2,3,4,5].partition(e => e <=3)
//> [[1, 2, 3], [4, 5]]
[1,2,3,4,5]
is called a literal. The Literal is one way to create an object. We can also use factory functions or Object.create to create the same array:
// Literal
[1, 2, 3, 4, 5];
// Factory Function
Array(1, 2, 3, 4, 5);
// Object.create
let arr = Object.create(Array.prototype);
arr.push(1);
arr.push(2);
arr.push(3);
arr.push(4);
arr.push(5);
A factory function is any function that takes a few arguments and returns a new object composed of those arguments. In JavaScript, any function can return an object. When it does so without the new
keyword, it’s a factory function. Factory functions have always been attractive in JavaScript because they offer the ability to easily produce object instances without diving into the complexities of classes and the new
keyword.
In the code above, we created an object called arr
using Object.create
and pushed 5 elements into the array. arr
comes with all the functions inherited from the Array
prototype such as map
, pop
, slice
, and even partition
that we just created for the Array
prototype. Let’s add some more functionality to the arr
object:
arr.hello = () => "hello";
Pop Quiz! What’s going to be returned when we run the following code?
arr.partition((e) => e < 3); // #1
arr.hello(); // #2
let foo = [1, 2, 3];
foo.hello(); // #3
Array.prototype.bye = () => "bye";
arr.bye(); // #4
foo.bye(); // #5
Answers
- #1 is going to return
[[1,2], [3,4,5]]
becausepartition
is defined forArray
, whicharr
inherits from. - #2 is going to return “hello” because we created a new function for
arr
object calledhello
that takes no arguments and returns the string “hello”. - For #3, If you guessed “TypeError: foo.hello is not a function”, you are correct. Since
foo
is a new object created from theArray
prototype andhello
is not defined forArray
,hello
will not be defined forfoo
. - #4 and #5 are both going to return “bye” because in the line above, we added the function
bye
to theArray
prototype from whicharr
andfoo
both inherit. Any changes to the prototype will affect every object that inherits from the object, even after the object has been created.
Now we understand the fundamental of prototype, let’s go back to the previous example and create Person
and User
using prototypal inheritance:
function Person(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
Person.prototype.getFullName = function () {
return this.firstName + " " + this.lastName;
};
Now we can use the Person
prototype like so:
let person = new Person("Dan", "Abramov");
person.getFullName(); //> Dan Abramov
person
is an object. Doing a console.log(person)
gives us the following:
Person {
firstName: "Dan",
lastName: "Abramov",
__proto__: {
getFullName: f
constructor: f Person(firstName, lastName)
},
__proto__: Object
}
For our User
, we just need to extend the Person
class:
function User(firstName, lastName, email, password) {
Person.call(this, firstName, lastName); // call super constructor.
this.email = email;
this.password = password;
}
User.prototype = Object.create(Person.prototype);
User.prototype.setEmail = function (email) {
this.email = email;
};
User.prototype.getEmail = function () {
return this.email;
};
user.setEmail("dan@abramov.com");
user
is an object. Doing a console.log(user)
gives us the following:
User {
firstName: "Dan",
lastName: "Abramov",
email: "dan@abramov.com",
password: "iLuvES6",
__proto__: Person {
getEmail: f ()
setEmail: f (email)
__proto__: {
getFullName: f,
constructor: f Person(firstName, lastName)
__proto__: Object
}
}
}
What if we want to customize the getFullName
function for User
? How is the following code going to affect person
and user
?
User.prototype.getFullName = function () {
return "User Name: " + this.firstName + " " + this.lastName;
};
user.getFullName(); //> "User Name: Dan Abramov"
person.getFullName(); //> "Dan Abramov"
As we expect, person
is not be affected at all.
How about decorating the Person
object by adding a gender attribute and corresponding getter and setter functions?
Person.prototype.setGender = function (gender) {
this.gender = gender;
};
Person.prototype.getGender = function () {
return this.gender;
};
person.setGender("male");
person.getGender(); //> male
user.getGender(); //> returns undefined ... but is a function
user.setGender("male");
user.getGender(); //> male
Both person
and user
are affected because User
is prototyped from Person
so if Person
changes, User
changes too.
The decorator pattern from prototypal inheritance is not so different from the classical inheritance.
Classes vs. Prototypes
Dan Abramov advices that
- Classes obscure the prototypal inheritance at the core of JS.
- Classes encourage inheritance but you should prefer composition.
- Classes tend to lock you into the first bad design you came up with.
Instead of creating a class hierarchy, consider creating several factory functions. They may call each other in chain, tweaking the behavior of each other. You may also teach the “base” factory function to accept a “strategy” object modulating the behavior, and have the other factory functions provide it.
Unlike most other languages, JavaScript’s object system is based on prototypes, not classes. Unfortunately, most JavaScript developers don’t understand JavaScript’s object system, or how to put it to best use.
A Third way: No OOP
The three cornerstones of OOP - Inheritance, Encapsulation, and Polymorphism - are powerful programming tools/concepts but have their shortcomings:
Inheritance
Inheritance promotes code reuse but you are often forced to take more than what you want.
Joe Armstrong (creator of Erlang) puts it best:
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.
So what if there’s more than what we ask for? Can’t we just ignore the stuff we don’t need? Only if it’s that simple. When we need classes that depend on other classes, which depend on other classes, we’re going to have to deal with dependency hell, which really slows down the build and debugging processes. Additionally, applications that carry a long chain of dependencies are not very portable.
There’s of course the fragile base class problem as mentioned above. It’s unrealistic to expect everything to fall neatly into place when we create mappings between real-world objects and their classes. Inheritance is not forgiving when you need to refactor your code, especially the base class. Also, inheritance weakens encapsulation, the next cornerstone of OOP:
The problem is that if you inherit an implementation from a superclass and then change that implementation, the change from the superclass ripples through the class hierarchy. This rippling effect potentially affects all the subclasses.
Encapsulation
Encapsulation keeps every object’s internal state variables safe from the outside. The ideal case is that your program would consist of “islands of objects” each with their own states passing messages back and forth. This sounds like a good idea in theory if you are building a perfectly distributed system but in practice, designing a program consisting of perfectly self-contained objects is hard and limiting.
Lots of real world applications require solving difficult problems with many moving parts. When you take an OOP approach to design your application, you’re going to run into conundrums like how do you divide up the functionalities of your overall applications between different objects and how to manage interactions and data sharing between different objects. This article has some interesting points about the design challenges OOP applications:
When we consider the needed functionality of our code, many behaviors are inherently cross-cutting concerns and so don’t really belong to any particular data type. Yet these behaviors have to live somewhere, so we end up concocting nonsense Doer classes to contain them…And these nonsense entities have a habit of begetting more nonsense entities: when I have umpteen Manager objects, I then need a ManagerManager.
It’s true. I’ve seen “ManagerManager classes” in production software that wasn’t originally designed to be this way has grown in complexity over the years.
As we will see next when I introduce function composition (the alternative to OOP), we have something much simpler than objects that encapsulates its private variables and performs a specific task - it’s called functions!
But before we go there, we need to talk about the last cornerstone of OOP:
Polymorphism
Polymorphism let’s us specify behavior regardless of data type. In OOP, this means designing a class or prototype that can be adapted by objects that need to work with different kinds of data. The objects that use the polymorphic class/prototype needs to define type-specific behavior to make it work. Let’s see an example.
Suppose to want to create a general (polymorphic) object that takes some data and a status flag as parameters. If the status says the data is valid (i.e., status === true
), a function can be applied onto the data and the result, along with the status flag, will be returned. If the status flags the data as invalid, then the function will not be applied onto the data and the data, along with the invalid status flag, will be returned.
Let’s start with creating a polymorphic prototype object called Maybe
:
function Maybe({ data, status }) {
this.data = data;
this.status = status;
}
Maybe
is a wrapper for data
. To wrap the data
in Maybe
, we provide an additional field called status
that indicates if the data is valid or not.
We can make Maybe
a prototype with a function called apply
, which takes a function and applies it on the data only if the status of the data indicates that it is valid.
Maybe.prototype.apply = function (f) {
if (this.status) {
return new Maybe({ data: f(this.data), status: this.status });
}
return new Maybe({ data: this.data, status: this.status });
};
We can add another function to the Maybe
prototype which gets the data or returns a message if there’s an error with the data.
Maybe.prototype.getOrElse = function (msg) {
if (this.status) return this.data;
return msg;
};
Now we create two objects from the Maybe
prototype called Number
:
function Number(data) {
let status = typeof data === "number";
Maybe.call(this, { data, status });
}
Number.prototype = Object.create(Maybe.prototype);
and String
:
function String(data) {
let status = typeof data === "string";
Maybe.call(this, { data, status });
}
String.prototype = Object.create(Maybe.prototype);
Let’s see our objects in action. We create a function called increment
that’s only defined for numbers and another function called split
that’s only defined for strings:
const increment = (num) => num + 1;
const split = (str) => str.split("");
Because JavaScript is not type safe, it won’t prevent you from incrementing a string or splitting a number. You will see a runtime error when you uses an undefined method on a data type. For example, suppose we try the following:
let foop = 12;
foop.split("");
That’s going to give you a type error when you run the code.
However, if we used our Number
and String
objects to wrap the numbers and strings before operating on them, we can prevent these run time errors:
let numValid = new Number(12);
let numInvalid = new Number("foo");
let strValid = new String("hello world");
let strInvalid = new String(-1);
let a = numValid.apply(increment).getOrElse("TypeError!");
let b = numInvalid.apply(increment).getOrElse("TypeError Oh no!");
let c = strValid.apply(split).getOrElse("TypeError!");
let d = strInvalid.apply(split).getOrElse("TypeError :(");
What will the following print out?
console.log({ a, b, c, d });
Since we designed our Maybe
prototype to only apply the function onto the data if the data is the right type, this will be logged to console:
{
a: 13,
b: 'TypeError Oh no!',
c: [ 'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd' ],
d: 'TypeError :('
}
What we just did is a type of a monad (albeit I didn’t implement Maybe
to follow all the monad laws). The Maybe
monad is a wrapper that’s used when a value can be absent or some validation can fail and you don’t care about the exact cause. Typically this can occur during data retrieval and validation. Maybe handles failure in validation or failure in applying a function similar to the try-catch
you’ve likely seen before. In Maybe
, we are handling the failure in type validation by printing to a string, but we can easily revise the getOrElse
function to call another function which handles the validation error.
Some programming languages like Haskell come with a built-in monad type but in JavaScript, you have to roll your own. ES6 introduced Promise
, which is a monad for dealing with latency. Sometimes you need data that could take a while to retrieve. Promise
lets you write code that appears synchronous while delaying operation on the data until the data becomes available. Using Promise
is a cleaner way of asynchronous programming than using callback functions, which could lead to a phenomenon called the callback hell.
Composition
As alluded to earlier, there’s something much simpler than class/prototypes which can be easily reused, encapsulates internal states, performs a given operation on any type of data, and be polymorphic - it’s called functional composition.
JavaScript easily lets us bundle related functions and data together in an object:
const Person = {
firstName: "firstName",
lastName: "lastName",
getFullName: function () {
return `${this.firstName} ${this.lastName}`;
},
};
Then we can use the Person
object directly like this:
let person = Object.create(Person);
person.getFullName(); //> "firstName lastName"
// Assign internal state variables
person.firstName = "Dan";
person.lastName = "Abramov";
// Access internal state variables
person.getFullName(); //> "Dan Abramov"
Let’s make a User
object by cloning the Person
object, then augmenting it with additional data and functions:
const User = Object.create(Person);
User.email = "";
User.password = "";
User.getEmail = function () {
return this.email;
};
Then we can create an instance of user using Object.create
let user = Object.create(User);
user.firstName = "Dan";
user.lastName = "Abramov";
user.email = "dan@abramov.com";
user.password = "iLuvES6";
A gotcha here is use Object.create
whenever you want to copy. Objects in JavaScript are mutable so when you straight out assigning to create a new object and you mutate the second object, it will change the original object!
Except for numbers, strings, and boolean, everything in JavaScript is an object.
// Wrong
const arr = [1, 2, 3];
const arr2 = arr;
arr2.pop();
arr; //> [1,2]
In the above example, I used const
to show that it doesn’t protect you from mutating objects. Objects are defined by their reference so while const
prevents you from reassigning arr
, it doesn’t make the object “constant”.
Object.create
makes sure we are copying an object instead of passing its reference around.
Like Lego pieces, we can create copies of the same objects and tweak them, compose them, and pass them onto other objects to augment the capability of other objects.
For example, we define a Customer
object with data and functions. When our User
converts, we want to add the Customer
stuff to our user instance.
const Customer = {
plan: "trial",
};
Customer.setPremium = function () {
this.plan = "premium";
};
Now we can augment user object with an Customer methods and fields.
User.customer = Customer;
user.customer.setPremium();
After running the above two lines of codes, this becomes our user
object:
{
firstName: 'Dan',
lastName: 'Abramov',
email: 'dan@abramov.com',
password: 'iLuvES6',
customer: { plan: 'premium', setPremium: [Function] }
}
When we want to supply a object with some additional capability, higher order objects cover every use case.
As shown in the example above, we should favor composition over class inheritance because composition is simpler, more expressive, and more flexible:
Classical inheritance creates is-a relationships with restrictive taxonomies, all of which are eventually wrong for new use-cases. But it turns out, we usually employ inheritance for has-a, uses-a, or can-do relationships.
Conclusion
Programmers often have to make a tradeoff between Code reusability and code scalability. Classical OOP probably makes sense for enterprise software because they don’t change that much. In OOP behavior is hard coded in abstract classes but is configurable to some extent during construction. This promotes better code reuse, which saves developers a lot of time upfront. However, if you expect your code to extend more capability later and have to revise your design many times in the future, then OOP will end up hurting developer productivity and make the code highly coupled with the environment and untestable.
Resources
- JavaScript Factory Functions vs Constructor Functions vs Classes
- How to Use Classes and Sleep at Night
- Master the JavaScript Interview: What’s the Difference Between Class & Prototypal Inheritance?
- ES6 — classes and inheritance
- JavaScript Objects In Detail
- Factory Function Patterns In Depth
- Elegant patterns in modern JavaScript: Ice Factory
- Small Functions considered Harmful
- Prototypes In JavaScript