Prerequisite: This post presumes that readers are familiar with JavaScript’s objects, know how a prototype defines behaviour for an object, know what a constructor function is, and how a constructor’s .prototype
property is related to the objects it constructs. Passing familiarity with ECMAScript 2015 syntax like let
and gathering parameters will be extremely helpful.
We have always been able to create a JavaScript class like this:
function Person (first, last) {
this.rename(first, last);
}
Person.prototype.fullName = function fullName () {
return this.firstName + " " + this.lastName;
};
Person.prototype.rename = function rename (first, last) {
this.firstName = first;
this.lastName = last;
return this;
}
Person
is a constructor function, and it’s also a class, in the JavaScript sense of the word “class.” As we’ve written it here, it’s a function declaration. But let’s rewrite it as a function expression. We’ll use let
just to get into the ECMAScript 2015 swing of things (many people would use const
, that doesn’t matter here):
let Person = function (first, last) {
this.rename(first, last);
}
Person.prototype.fullName = function fullName () {
return this.firstName + " " + this.lastName;
};
Person.prototype.rename = function rename (first, last) {
this.firstName = first;
this.lastName = last;
return this;
}
classes with class
ECMAScript 2015 provides the class
keyword and “compact method notation” as syntactic sugar for writing a function and assigning methods to its prototype (there is a little more involved, but that isn’t relevant here). So we can now write our Person
class like this:
class Person {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this.firstName + " " + this.lastName;
}
rename (first, last) {
this.firstName = first;
this.lastName = last;
return this;
}
};
Just like a function declaration, we can also write a class expression:
let Person = class {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this.firstName + " " + this.lastName;
}
rename (first, last) {
this.firstName = first;
this.lastName = last;
return this;
}
};
This is interesting, because it shows that creating a class in JavaScript (whether we write constructor functions or use the class
keyword) is evaluating an expression. In this case, our class is created anonymously, we just happen to bind it to Person
.1 We can create classes, assign them to variables, pass them to functions, or return them from functions, just like any other value in JavaScript.
That’s a very powerful thing. Not all OOP languages do things that way, some have classes, but they aren’t values. Some have classes with names, but the names live in a special space that is separate from the variables we bind. But having classes be “just a function” and having prototypes be “just an object” means they are “just values.” And that lets us do anything with a class or a prototype we could do with any other value.
Like what? I’m glad you asked. First, let’s review the ECMAScript 2015 Symbol
:
symbols
In its simplest form, Symbol
is a function that returns a unique entity. No two symbols are alike, ever:
Symbol() !=== Symbol()
Symbols have string representations, although they may appear cryptic:2
Symbol().toString()
//=> Symbol(undefined)_u.mwf0blvw5
Symbol().toString()
//=> Symbol(undefined)_s.niklxrko8m
Symbol().toString()
//=> Symbol(undefined)_s.mbsi4nduh
You can add your own text to help make it intelligible:
Symbol("Allongé").toString()
//=> Symbol(Allongé)_s.52x692eab
Symbol("Allongé").toString()
//=> Symbol(Allongé)_s.q6hq5lx01p
Symbol("Allongé").toString()
//=> Symbol(Allongé)_s.jii7eyiyza
There are some ways that JavaScript makes symbols especially handy. Using symbols as property names, for example.
a problem with encapsulation
One of the huge problems with OOP in JavaScript is that it is very easy for code to become highly coupled. By default, all methods and properties are “public,” any piece of code can read and write any property. In our Person
, it looks very much to the eye like firstName
and lastName
are intended to be private, while other objects interact with a person using the .rename
and .fullName
methods.
The usual argument against other code reading or writing .firstName
and .lastName
directly is that makes it difficult to modify the Person
class. Imagine that we wish to accommodate an optional middle name:
class Person {
constructor (first, last, middle) {
this.rename(first, last, middle);
}
fullName () {
return this.middleName
? (this.firstName + " " + this.middleName + " " + this.lastName)
: (this.firstName + " " + this.lastName);
}
rename (first, last, middle) {
this.firstName = first;
this.lastName = last;
this.middleName = middle;
return this;
}
};
How awkward, but so far nothing breaks, not even the code that directly accesses .firstName
and .lastName
. Now we refactor:
class Person {
constructor (...names) {
this.rename(...names);
}
fullName () {
return this.names.join(" ");
}
rename (...names) {
this.names = names;
return this;
}
};
Presto, we just broke everything that depends directly upon .firstName
and .lastName
.
The problem here is that all code has dependencies. The code using Person
depends upon it’s behaviour. The trouble is, code that manipulates .firstName
and .lastName
depends upon both the “interface” and the “implementation” of Person
, which makes it difficult to change Person
in the future. And it’s not just Person
that can’t change. We won’t write them out here, but every piece of code that uses Person
depend upon each other using it correctly. If one writes the wrong thing in .firstName
or .lastName
, all the other pieces of code using Person
could break.
This may seem very theoretical. But as applications and teams grow, and deadlines loom, and SEV-1 incidents occur, the best of intentions get watered down, and over time, the code gradually becomes fragile. This has been known since the 1960s, and gave rise to Modular Programming, where a hard separation was made between the implementation inside a module, and the interface it exposed to the rest of the code. “OOP” embraced this with the premise that every object encapsulates its own implementation.3
But our Person
class does not encapsulate its implementation. Let’s use symbols to do so:
using symbols to encapsulate private properties
In ECMAScript 2015, a symbol can be a property name. So if we arrange things such that a class’s methods have a symbol in scope, but no other code has that symbol in scope, we can create relatively private properties.4
As you probably know, writing foo.bar
is synonymous with foo['bar']
. Same thing semantically. So let’s begin by rewriting Person
to use strings for property keys:
class Person {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this['firstName'] + " " + this['lastName'];
}
rename (first, last) {
this['firstName'] = first;
this['lastName'] = last;
return this;
}
};
So far, exactly the same behaviour, any code that wants to, can access a person’s .firstName
or .lastName
. Next, we’ll extract some variables:
let firstNameProperty = 'firstName',
lastNameProperty = 'lastName';
class Person {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this[firstNameProperty] + " " + this[lastNameProperty];
}
rename (first, last) {
this[firstNameProperty] = first;
this[lastNameProperty] = last;
return this;
}
};
Same thing, but we aren’t done yet. Let’s use symbols instead of strings:
let firstNameProperty = Symbol('firstName'),
lastNameProperty = Symbol('lastName');
class Person {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this[firstNameProperty] + " " + this[lastNameProperty];
}
rename (first, last) {
this[firstNameProperty] = first;
this[lastNameProperty] = last;
return this;
}
};
This is different. Instances of Person
won’t have properties like .lastName
, they will be properties like ['Symbol(lastName)_v.cn3u8ad08']
. Furthermore, JavaScript automatically makes these properties non-enumerable, so they won’t show up should we use things like for...in
loops.
So it will be difficult for other code to directly manipulate the properties we use for a person’s first and last name. But that being said, we’re “exposing” the firstNameProperty
and lastNameProperty
variables to the world. We’ve encapsulated instances of Person
, but not Person
itself.
encapsulating our class implementation
Recall that we said a class is a value that can be assigned to a variable or returned from a function. Functions are excellent mechanisms for encapsulating code. Let’s start by changing our class declaration into a class expression. We’ll make this one a named class expression to help with debugging and what-not:
let firstNameProperty = Symbol('firstName'),
lastNameProperty = Symbol('lastName');
let Person = class Person {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this[firstNameProperty] + " " + this[lastNameProperty];
}
rename (first, last) {
this[firstNameProperty] = first;
this[lastNameProperty] = last;
return this;
}
};
Now we wrap the class in an IIFE5:
let firstNameProperty = Symbol('firstName'),
lastNameProperty = Symbol('lastName');
let Person = (() = > {
return class Person {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this[firstNameProperty] + " " + this[lastNameProperty];
}
rename (first, last) {
this[firstNameProperty] = first;
this[lastNameProperty] = last;
return this;
}
};
)();
And finally, we move the property name variables inside the IIFE:
let Person = (() = > {
let firstNameProperty = Symbol('firstName'),
lastNameProperty = Symbol('lastName');
return class Person {
constructor (first, last) {
this.rename(first, last);
}
fullName () {
return this[firstNameProperty] + " " + this[lastNameProperty];
}
rename (first, last) {
this[firstNameProperty] = first;
this[lastNameProperty] = last;
return this;
}
};
)();
Now this is different. Code outside the IIFE cannot see the property names. We construct a class and return it from the IIFE. We then assign it to the Person
variable. Its mechanism has been completely encapsulated.
commentary
Other languages have features like private instance variables, of course. But what makes JavaScript different from languages like Java or C++ is that JavaScript’s flexibility gave us the tools to construct our own way to encapsulate properties inside an instance, and to encapsulate the construction of a class inside an IIFE.
This pattern of creating a class that has private variables emerged from combining a few things: The fact that instance variables are properties, the fact that we can use symbols as non-enumerable and hard-to-guess property keys, and the fact that class
can be used as an expression.
There’s no need to have special keywords or magic namespaces. That keeps the “surface area” of the language small, and provides a surprising amount of flexibility. If we want to, we can build mixins, traits, eigenclasses, and all sorts of other constructs that have to be baked into other languages.
(discuss on hacker news)
more reading:
- Prototypes are Objects (and why that matters)
- Classes are Expressions (and why that matters)
- Functional Mixins in ECMAScript 2015
- Using ES.later Decorators as Mixins
- Method Advice in Modern JavaScript
super()
considered hmmm-ful- JavaScript Mixins, Subclass Factories, and Method Advice
- This is not an essay about ‘Traits in Javascript’
notes:
-
JavaScript engines will “infer” that the otherwise anonymous function expression should be named
Person
because it is immediately assigned to a variable of that name. There are ways to create a truly anonymous constructor function or “class” and bind it to a name, but that isn’t relevant here. ↩ -
The exact representation depends upon the implementation ↩
-
Some teams take a coöperative approach to separating interfaces from implementation. They might, for example, name the properties
_firstName
and_lastName
, with the understanding that anything prefixed with an underscore was off-limits. Usually, this works for a while, but the moment someone breaks the “rule,” the team needs to crack down on the “violation” immediately. If there is one exception in the code, another one will spring up via copy-and-paste, and then another, and then it entirely breaks down. So some teams look around for linting tools to identify people breaking the rule as early as possible. And if we’re to accept that tooling is a good idea after the code is written, why not write the code in such a way that it discourages violations in the first place? ↩ -
There are still ways to get around this form of privacy, but they are sufficiently awkward that they will discourage excessive coupling and stand out like a sore thumb at code reviews. ↩
-
“Immediately Invoked Function Expressions” ↩