NOTE: |
---|
Work in progress |
The class-design pattern generally entails defining a type of thing (class), including data (members) and behaviors (methods), and then creating one or more concrete instances of this class definition as actual objects that can interact and perform tasks. Moreover, class-orientation allows declaring a relationship between two or more classes, through what's called "inheritance", to derive new and augmented "subclasses" that mix-n-match and even re-define behaviors.
Prior to ES6 (2015), JS developers mimicked aspects of class-oriented (aka "object-oriented") design using plain functions and objects, along with the [[Prototype]]
mechanism (as explained in the previous chapter) -- so called "prototypal classes".
But to many developers joy and relief, ES6 introduced dedicated syntax, including the class
and extends
keywords, to express class-oriented design more declaratively.
At the time of ES6's class
being introduced, this new dedicated syntax was almost entirely just syntactic sugar to make class definitions more convenient and readable. However, in the many years since ES6, class
has matured and grown into its own first class feature mechanism, accruing a significant amount of dedicated syntax and complex behaviors that far surpass the pre-ES6 "prototypal class" capabilities.
Even though class
now bears almost no resemblance to older "prototypal class" code style, the JS engine is still just wiring up objects to each other through the existing [[Prototype]]
mechanism. In other words, class
is not its own separate pillar of the language (as [[Prototype]]
is), but more like the fancy, decorative Capital that tops the pillar/column.
That said, since class
style code has now replaced virtually all previous "prototypal class" coding, the main text here focuses only on class
and its various particulars. For historical purposes, we'll briefly cover the old "prototypal class" style in an appendix.
Class-orientation is a design pattern, which means it's a choice for how you organize the information and behavior in your program. It has pros and cons. It's not a universal solution for all tasks.
So how do you know when you should use classes?
In a theoretical sense, class-orientation is a way of dividing up the business domain of a program into one or more pieces that can each be defined by an "is-a" classification: grouping a thing into the set (or sets) of characteristics that thing shares with other similar things. You would say "X is a Y", meaning X has (at least) all the characteristics of a thing of kind Y.
For example, consider computers. We could say a computer is electrical, since it uses electrical current (voltage, amps, etc) as power. It's furthermore electronic, because it manipulates the electrical current beyond simply routing electrons around (electrical/magnetic fields), creating a meaningful circuit to manipulate the current into performing more complex tasks. By contrast, a basic desk lamp is electrical, but not really electronic.
We could thus define a class Electrical
to describe what electrical devices need and can do. We could then define a further class Electronic
, and define that in addition to being electrical, Electronic
things manipulate electricity to create more specialized outcomes.
Here's where class-orientation starts to shine. Rather than re-define all the Electrical
characteristics in the Electronic
class, we can define Electronic
in such a way that it "shares" or "inherits" those characteristics from Electrical
, and then augments/redefines the unique behaviors that make a device electronic. This relationship between the two classes -- called "inheritance" -- is a key aspect of class-orientation.
So class-orientation is a way of thinking about the entities our program needs, and classifying them into groupings based on their characteristics (what information they hold, what operations can be performed on that data), and defining the relationships between the different grouping of characteristics.
But moving from the theoretical into in a bit more pragmatic perspective: if your program needs to hold and use multiple collections (instances) of alike data/behavior at once, you may benefit from class-orientation.
Here's a short illustration.
A couple of decades ago, right after I had gone through nearly all of a Computer Science degree in college, I found myself sitting in my first professional software developer job. I was tasked with building, all by myself, a timesheet and payroll tracking system. I built the backend in PHP (using MySQL for the DB) and used JS for the interface (early as it was in its maturity way back around the turn of the century).
Since my CS degree had emphasized class-orientation heavily throughout my courses, I was eager to put all that theory to work. For my program's design, I defined the concept of a "timesheet" entity as a collection of 2-3 "week" entities, and each "week" as a collection of 5-7 "day" entities, and each "day" as a collection of "task" entities.
If I wanted to know how many hours were logged into a timesheet instance, I could call a totalTime()
operation on that instance. The timesheet defined this operation by looping over its collection of weeks, calling totalTime()
on each of them and summing the values. Each week did the same for all its days, and each day did the same for all its tasks.
The notion being illustrated here, one of the fundamentals of design patterns like class-orientation, is called encapsulation. Each entity level encapsulated (e.g., controlled, hid, abstracted) internal details (data and behavior) while presenting a useful external interface.
But encapsulation alone isn't a sufficient justification for class-orientation. Other design patterns offer sufficient encapsulation.
How did my class design take advantage of inheritance? I had a base class that defined a set of operations like totalTime()
, and each of my entity class types extended/subclassed this base class. That meant that each of them inherited this summation-of-total-time capability, but where each of them applied their own extensions and definitions for the internal details of how to do that work.
There's yet another aspect of the design pattern at play, which is composition: each entity was defined as a collection of other entities.
I mentioned above that a pragmatic way of deciding if you need class-orientation is if your program is going to have multiple instances of a single kind/type of behavior (aka, "class"). In the timesheet example, we had 4 classes: Timesheet, Week, Day, and Task. But for each class, we had multiple instances of each at once.
Had we instead only needed a single instance of a class, like just one Computer
thing that was an instance of the Electronic
class, which was a subclass of the Electrical
class, then class-orientation may not offer quite as much benefit. In particular, if the program doesn't need to create an instance of the Electrical
class, then there's no particular benefit to separating Electrical
from Electronic
, so we aren't really getting any help from the inheritance aspect of class-orientation.
So, if you find yourself designing a program by dividing up a business problem domain into different "classes" of entities, but in the actual code of the program you are only ever need one concrete thing of one kind/definition of behavior (aka, "class"), you might very well not actually need class-orientation. There are other design patterns which may be a more efficient match to your effort.
But if you find yourself wanting to define classes, and subclasses which inherit from them, and if you're going to be instantiating one or more of those classes multiple times, then class-orientation is a good candidate. And to do class-orientation in JS, you're going to need the class
keyword.
class
defines either a declaration or expression for a class. As a declaration, a class definition appears in a statement position and looks like this:
class Point2d {
// ..
}
As an expression, a class definition appears in a value position and can either have a name or be anonymous:
// named class expression
const pointClass = class Point2d {
// ..
};
// anonymous class expression
const anotherClass = class {
// ..
};
The contents of a class
body typically include one or more method definitions:
class Point2d {
setX(x) {
// ..
}
setY(y) {
// ..
}
}
Inside a class
body, methods are defined without the function
keyword, and there's no ,
or ;
separators between the method definitions.
NOTE: |
---|
Inside a class block, all code runs in strict-mode even without the "use strict" pragma present in the file or its functions. In particular, this impacts the this behavior for function calls, as explained in Chapter 4. |
One special method that all classes have is called a "constructor". If omitted, there's a default empty constructor assumed in the definition.
The constructor is invoked any time a new
instance of the class is created:
class Point2d {
constructor() {
console.log("Here's your new instance!");
}
}
var point = new Point2d();
// Here's your new instance!
Even though the syntax implies a function actually named constructor
exists, JS defines a function as specified, but with the name of the class (Point2d
above):
typeof Point2d; // "function"
It's not just a regular function, though; this special kind of function behaves a bit differently:
Point2d.toString();
// class Point2d {
// ..
// }
Point2d();
// TypeError: Class constructor Point2d cannot
// be invoked without 'new'
Point2d.call({});
// TypeError: Class constructor Point2d cannot
// be invoked without 'new'
You can construct as many different instances of a class as you need:
var one = new Point2d();
var two = new Point2d();
var three = new Point2d();
Each of one
, two
, and three
here are objects that are independent instances of the Point2d
class.
NOTE: |
---|
Each of the one , two , and three objects have a [[Prototype]] linkage to the Point2d.prototype object (see Chapter 2). In this code, Point2d is both a class definition and the constructor function of the same name. |
If you add a property to the object one
:
one.value = 42;
That property now exists only on one
, and does not exist in any way that the independent two
or three
objects can access:
two.value; // undefined
three.value; // undefined
As shown above, a class definition can include one or more method definitions:
class Point2d {
constructor() {
console.log("Here's your new instance!");
}
setX(x) {
console.log(`Setting x to: ${x}`);
// ..
}
}
var point = new Point2d();
point.setX(3);
// Setting x to: 3
The setX
property (method) looks like it exists on (is owned by) the point
object here. But that's a mirage. Each class method is added to the prototype
object, a property of the constructor function.
So, setX(..)
only exists as Point2d.prototype.greeting
. Since point
is [[Prototype]]
linked to Point2d.prototype
(see Chapter 2) via the new
keyword instantiation, the point.setX(..)
reference traverses the [[Prototype]]
chain and finds the method to execute.
Class methods should only be invoked via an instance; Point2d.setX(..)
doesn't work because there is no such property. You could invoke Point2d.prototype.setX(..)
, but that's not generally proper/advised in standard class-oriented coding. Always access class methods via the instances.
We will cover the this
keyword in much more detail in a subsequent chapter. But as it relates to class-oriented code, the this
keyword generally refers to the current instance that is the context of any method invocation.
In the constructor, as well as any methods, you can use this.
to either add or access properties on the current instance:
class Point2d {
constructor(x,y) {
// add properties to the current instance
this.x = x;
this.y = y;
}
toString() {
// access the properties from the current instance
console.log(`(${this.x},${this.y})`);
}
}
var point = new Point2d(3,4);
point.x; // 3
point.y; // 4
point.toString(); // (3,4)
Any properties not holding function values, which are added to a class instance (usually via the constructor), are referred to as members, as opposed to the term methods for executable functions.
While the point.toString()
method is running, its this
reference is pointing at the same object that point
references. That's why both point.x
and this.x
reveal the same 3
value that the constructor set with its this.x = x
operation.
Instead of defining a class instance member imperatively via this.
in the constructor or a method, classes can declaratively define fields in the class
body, which correspond directly to members that will be created on each instance:
class Point2d {
// this is a public field
x = 0
y = 0
constructor(x,y) {
// set properties (fields) on the current instance
this.x = x;
this.y = y;
}
toString() {
// access the properties from the current instance
console.log(`(${this.x},${this.y})`);
}
}
Public fields can have a value initialization, as shown above, but that's not required. If you don't initialize a field in the class definition, you almost always should initialize it in the constructor.
Fields can also reference each other, via natural this.
access syntax:
class Point3d {
// these are public fields
x
y = 4
z = this.y * 5
// ..
}
TIP: |
---|
You can think of public field declarations as if they appear at the top of the constructor, each prefixed with an implied this. that you get to omit in the declarative class body form. |
Just like computed property names (see Chapter 1), field names can be computed:
var coordName = "x";
class Point2d {
// computed public field
[coordName.toUpperCase()] = 42
// ..
}
var point = new Point2d(3,4);
point.x; // 3
point.y; // 4
point.X; // 42
One pattern that has emerged and grown quite popular, but which I firmly believe is an anti-pattern for class
, looks like the following:
class Point2d {
x = null
y = null
getDoubleX = () => this.x * 2
constructor(x,y) {
this.x = x;
this.y = y;
}
toString() { /* .. */ }
}
var point = new Point2d(3,4);
point.getDoubleX(); // 6
See the field holding an =>
arrow function? I say this is a no-no. But why? Let's unwind what's going on.
First, why do this? Because JS developers seem to be perpetually frustrated by the dynamic this
binding rules (see Chapter 4), so they force a this
binding via the =>
arrow function. That way, no matter how getDoubleX()
is invoked, it's always this
-bound to the particular instance. That's an understandable convenience to desire, but... it betrays the very nature of the this
/ [[Prototype]]
pillar of the language. How?
Let's consider the equivalent code to the previous snippet:
class Point2d {
constructor(x,y) {
this.x = null;
this.y = null;
this.getDoubleX = () => this.x * 2;
this.x = x;
this.y = y;
}
toString() { /* .. */ }
}
var point = new Point2d(3,4);
point.getDoubleX(); // 6
Can you spot the problem? Look closely. I'll wait.
...
We've made it clear repeatedly so far that class
definitions put their methods on the class constructor's prototype
object -- that's where they belong! -- such that there's just one of each function and it's inherited (shared) by all instances. That's what will happen with toString()
in the above snippet.
But what about getDoubleX()
? That's essentially a class method, but it won't be handled by JS quite the same as toString()
will. Consider:
Object.hasOwn(point,"x"); // true -- good
Object.hasOwn(point,"toString"); // false -- good
Object.hasOwn(point,"getDoubleX"); // true -- oops :(
You see now? By defining a function value and attaching it as a field/member property, we're losing the shared prototypal method'ness of the function, and it becomes just like any per-instance property. That means we're creating a new function property for each instance, rather than it being created just once on the class constructor's prototype
.
That's wasteful in performance and memory, even if by a tiny bit. That alone should be enough to avoid it.
But I would argue that way more importantly, what you've done with this pattern is invalidate the very reason why using class
and this
-aware methods is even remotely useful/powerful!
If you go to all the trouble to define class methods with this.
references throughout them, but then you lock/bind most or all of those methods to a specific object instance, you've basically travelled all the way around the world just to go next door.
If all you want are function(s) that are statically fixed to a particular "context", and don't need any dynamicism or sharing, what you want is... closure. And you're in luck: I wrote a whole book in this series ("Scope & Closures") on how to use closure so functions remember/access their statically defined scope (aka "context"). That's a way more appropriate, and simpler to code, approach to get what you're after.
Don't abuse/misuse class
and turn it into a over-hyped, glorified collection of closure.
To be clear, I'm not saying: never use =>
arrow functions inside classes.
I am saying: never attach an =>
arrow function as an instance property in place of a dynamic prototypal class method, either out of mindless habit, or laziness in typing fewer characters, or misguided this
-binding convenience.
In a subsequent chapter, we'll dive deep into how to understand and properly leverage the full power of the dynamic this
mechanism.
The way to unlock the power of class inheritance is through the extends
keyword, which defines a relationship between two classes:
class Point2d {
x = 3
y = 4
getX() {
return this.x;
}
}
class Point3d extends Point2d {
x = 21
y = 10
z = 5
printDoubleX() {
console.log(`double x: ${this.getX() * 2}`);
}
}
var point new Point2d();
point.getX(); // 3
var anotherPoint = new Point3d();
anotherPoint.getX(); // 21
anotherPoint.printDoubleX(); // double x: 42
Take a few moments to re-read that code snippet and make sure you fully understand what's happening.
The base class Point2d
defines fields (members) called x
and y
, and gives them the initial values 3
and 4
, respectively. It also defines a getX()
method that accesses this x
instance member and returns it. We see that behavior illustrated in the point.getX()
method call.
But the Point3d
class extends Point2d
, making Point3d
a derived-class, child-class, or (most commonly) subclass. In Point3d
, the same x
property that's inherited from Point2d
is re-initialized with a different 21
value, as is the y
overriden to value from 4
, to 10
.
It also adds a new z
field/member method, as well as a printDoubleX()
method, which itself calls this.getX()
.
When anotherPoint.printDoubleX()
is invoked, the inherited this.getX()
is thus invoked, and that method makes reference to this.x
. Since this
is pointing at the class instance (aka, anotherPoint
), the value it finds is now 21
(instead of 3
from the point
object's x
member).
// TODO: cover class Foo extends ..
where ..
is an expression, not a class-name
In addition to overriding a field/member in a subclass, you can also override (redefine) a method:
class Point2d {
x = 3
y = 4
getX() {
return this.x;
}
}
class Point3d extends Point2d {
x = 21
y = 10
z = 5
getX() {
return this.x * 2;
}
printX() {
console.log(`double x: ${this.getX()}`);
}
}
var point = new Point3d();
point.printX(); // double x: 42
The Point3d
subclass overrides the inherited getX()
method to give it different behavior. However, you can still instantiate the base Point2d
class, which would then give an object that uses the original ("That's ..."
) definition for greeting()
.
If you want to access an inherited method from a subclass even if it's been overriden, you can use super
instead of this
:
class Point2d {
x = 3
y = 4
getX() {
return this.x;
}
}
class Point3d extends Point2d {
x = 21
y = 10
z = 5
getX() {
return this.x * 2;
}
printX() {
console.log(`x: ${super.getX()}`);
}
}
var point = new Point3d();
point.printX(); // x: 21
The ability for methods of the same name, at different levels of the inheritance hierarchy, to exhibit different behavior when either accessed directly, or relatively with super
, is called polymorphism. It's a very powerful part of class-orientation, when used appropriately.
In addition to a subclass method accessing an inherited method definition (even if overriden on the subclass) via super.
reference, a subclass constructor can manually invoke the inherited base class constructor via super(..)
function invocation:
class Point2d {
x
y
constructor(x,y) {
this.x = x;
this.y = y;
}
}
class Point3d extends Point2d {
z
constructor(x,y,z) {
super(x,y);
this.z = z;
}
toString() {
console.log(`(${this.x},${this.y},${this.z})`);
}
}
var point = new Point3d(3,4,5);
point.toString(); // (3,4,5)
WARNING: |
---|
An explicitly defined subclass constructor must call super(..) to run the inherited class's initialization, and that must occur before the subclass constructor makes any references to this or finishes/returns. Otherwise, a runtime exception will be thrown when that subclass constructor is invoked (via new ). If you omit the subclass constructor, the default constructor automatically thankfully invokes super() for you. |
You may need to determine in a constructor if that class is being instantiated directly, or being instantiated from a subclass with a super()
call. We can use a special "pseudo property" new.target
:
class Point2d {
// ..
constructor(x,y) {
if (new.target === Point2) {
console.log("Constructing 'Point2d' instance");
}
}
// ..
}
class Point3d extends Point2d {
// ..
constructor(x,y,z) {
super(x,y);
if (new.target === Point3d) {
console.log("Constructing 'Point3d' instance");
}
}
// ..
}
var point = new Point2d(3,4);
// Constructing 'Point2d' instance
var anotherPoint = new Point3d(3,4,5);
// Constructing 'Point3d' instance
You may want to introspect a certain object instance to see if it's an instance of a specific class. We do this with the instanceof
operator:
class Point2d { /* .. */ }
class Point3d extends Point2d { /* .. */ }
var point = new Point2d(3,4);
point instanceof Point2d; // true
point instanceof Point3d; // false
var anotherPoint = new Point3d(3,4,5);
anotherPoint instanceof Point2d; // true
anotherPoint instanceof Point3d; // true
It may seem strange to see anotherPoint instanceof Point2d
result in true
. To understand why better, perhaps it's useful to visualize both [[Prototype]]
chains:
Point2d.prototype
/ \
/ \
/ \
point Point3d.prototype
\
\
\
anotherPoint
The instanceof
operator doesn't just look at the current object, but rather traverses the entire class inheritance hierarchy (the [[Prototype]]
chain) until it finds a match. Thus, anotherPoint
is an instance of both Point3d
and Point2d
.
To illustrate this fact a little more obviously, another (less ergonomic) way of going about the same kind of check as instanceof
is with the (inherited from Object.prototype
) utility, isPrototypeOf(..)
:
Point2d.prototype.isPrototypeOf(point); // true
Point3d.prototype.isPrototypeOf(point); // false
Point2d.prototype.isPrototypeOf(anotherPoint); // true
Point3d.prototype.isPrototypeOf(anotherPoint); // true
This utility makes it a little clearer why both Point2d.prototype.isPrototypeOf(anotherPoint)
and anotherPoint instanceof Point2d
result in true
: the object Point2d.prototype
is in the [[Prototype]]
chain of anotherPoint
.
If you instead wanted to check if the object instance was only and directly created by a certain class, check the instance's constructor
property.
point.constructor === Point2d; // true
point.constructor === Point3d; // false
anotherPoint.constructor === Point2d; // false
anotherPoint.constructor === Point3d; // true
NOTE: |
---|
The constructor property shown here is not actually present on (owned) the point or anotherPoint instance objects. So where does it come from!? It's on each object's [[Prototype]] linked prototype object: Point2d.prototype.constructor === Point2d and Point3d.prototype.constructor === Point3d . |
It may seem as if Point3d
, when it extends
the Point2d
class, is in essence getting a copy of all the behavior defined in Point2d
. Moreover, it may seem as if the concrete object instance anotherPoint
receives, copied down to it, all the methods from Point3d
(and by extension, also from Point2d
).
However, that's not the correct mental model to use for JS's implementation of class-orientation. Recall this base class and subclass definition, as well as instantiation of anotherPoint
:
class Point2d {
x
y
constructor(x,y) {
this.x = x;
this.y = y;
}
}
class Point3d extends Point2d {
z
constructor(x,y,z) {
super(x,y);
this.z = z;
}
toString() {
console.log(`(${this.x},${this.y},${this.z})`);
}
}
var anotherPoint = new Point3d(3,4,5);
If you inspect the anotherPoint
object, you'll see it only has the x
, y
, and z
properties (instance members) on it, but not the toString()
method:
Object.hasOwn(anotherPoint,"x"); // true
Object.hasOwn(anotherPoint,"y"); // true
Object.hasOwn(anotherPoint,"z"); // true
Object.hasOwn(anotherPoint,"toString"); // false
Where is that toString()
method located? On the prototype object:
Object.hasOwn(Point3d.prototype,"toString"); // true
And b
has access to that method via its [[Prototype]]
linkage (see Chapter 2). In other words, the prototype objects share access to their method(s) with the subclass(es) and instance(s). The method(s) stay in place, and are not copied down the inheritance chain.
As nice as the class
syntax is, don't forget what's really happening under the syntax: JS is just wiring up objects to each other along a [[Prototype]]
chain.
We've so far emphasized two different locations for data or behavior (methods) to reside: on the constructor's prototype, or on the instance. But there's a third option: on the constructor (function object) itself.
In a traditional class-oriented system, methods defined on a class are not concrete things you could ever invoke or interact with. You have to instantiate a class to have a concrete object to invoke those methods with. Prototypal languages like JS blur this line a bit: all class-defined methods are "real" functions residing on the constructor's prototype, and you could therefore invoke them. But as I asserted earlier, you really should not do so, as this is not how JS assumes you will write your class
es, and there are some weird corner-case behaviors you may run into. Best to stay on the narrow path that class
lays out for you.
Not all behavior that we define and want to associate/organize with a class needs to be aware of an instance. Moreover, sometimes a class needs to publicly define data (like constants) that developers using that class need to access, independent of any instance they may or may not have created.
So, how does a class system enable defining such data and behavior that should be available with a class but independent of (unaware of) instantiated objects? Static properties and functions.
NOTE: |
---|
I'll use "static property" / "static function", rather than "member" / "method", just so it's clearer that there's a distinction between instance-bound members / instance-aware methods, and non-instance properties and instance-unaware functions. |
We use the static
keyword in our class
bodies to distinguish these definitions:
class Point2d {
// class statics
static origin = new Point2d(0,0)
static distance(point1,point2) {
return Math.sqrt(
((point2.x - point1.x) ** 2) +
((point2.y - point1.y) ** 2)
);
}
// instance members and methods
x
y
constructor(x,y) {
this.x = x;
this.y = y;
}
toString() {
return `(${this.x},${this.y})`;
}
}
console.log(`Starting point: ${Point2d.origin}`);
// Starting point: (0,0)
var next = new Point2d(3,4);
console.log(`Next point: ${next}`);
// Next point: (3,4)
console.log(`Distance: ${
Point2d.distance( Point2d.origin, next )
}`);
// Distance: 5
The Point2d.origin
is a static property, which just so happens to hold a constructed instance of our class. And Point2d.distance(..)
is a static function that computes the 2-dimensional cartesian distance between two points.
Of course, we could have put these two somewhere other than as static
s on the class definition. But since they're directly related to the Point2d
class, it makes most sense to organize them there.
NOTE: |
---|
Don't forget that when you use the class syntax, the name Point2d is actually the name of a constructor function that JS defines. So Point2d.origin is just a regular property access on that function object. That's what I meant at the top of this section when I referred to a third location for storing things related to classes; in JS, static s are stored as properties on the constructor function. Take care not to confuse those with properties stored on the constructor's prototype (methods) and properties stored on the instance (members). |
The value in a static initialization (static whatever = ..
) can include this
references, which refers to the class itself (actually, the constructor) rather than to an instance:
class Point2d {
// class statics
static originX = 0
static originY = 0
static origin = new this(this.originX,this.originY)
// ..
}
WARNING: |
---|
I don't recommend actually doing the new this(..) trick I've illustrated here. That's just for illustration purposes. The code would read more cleanly with new Point2d(this.originX,this.originY) , so prefer that approach. |
An important detail not to gloss over: unlike public field initializations, which only happen once an instantiation (with new
) occurs, class static initializations always run immediately after the class
has been defined. Moreover, the order of static initializations matters; you can think of the statements as if they're being evaluated one at a time.
Also like class members, static properties do not have to be initialized (default: undefined
), but it's much more common to do so. There's not much utility in declaring a static property with no initialized value (static whatever
); Accessing either Point2d.whatever
or Point2d.nonExistent
would both result in undefined
.
Recently (in ES2022), the static
keyword was extended so it can now define a block inside the class
body for more sophisticated initialization of static
s:
class Point2d {
// class statics
static origin = new Point2d(0,0)
static distance(point1,point2) {
return Math.sqrt(
((point2.x - point1.x) ** 2) +
((point2.y - point1.y) ** 2)
);
}
// static initialization block (as of ES2022)
static {
let outerPoint = new Point2d(6,8);
this.maxDistance = this.distance(
this.origin,
outerPoint
);
}
// ..
}
Point2d.maxDistance; // 10
The let outerPoint = ..
here is not a special class
feature; it's exactly like a normal let
declaration in any normal block of scope (see the "Scope & Closures" book of this series). We're merely declaring a localized instance of Point2d
assigned to outerPoint
, then using that value to derive the assignment to the maxDistance
static property.
Static initialization blocks are also useful for things like try..catch
statements around expression computations.
Class statics are inherited by subclasses (obviously, as statics!), can be overriden, and super
can be used for base class references (and static function polymorphism), all in much the same way as inheritance works with instance members/methods:
class Point2d {
static origin = /* .. */
static distance(x,y) { /* .. */ }
static {
// ..
this.maxDistance = /* .. */;
}
// ..
}
class Point3d extends Point2d {
// class statics
static origin = new Point3d(
// here, `this.origin` references wouldn't
// work (self-referential), so we use
// `super.origin` references instead
super.origin.x, super.origin.y, 0
)
static distance(point1,point2) {
// here, super.distance(..) is Point2d.distance(..),
// if we needed to invoke it
return Math.sqrt(
((point2.x - point1.x) ** 2) +
((point2.y - point1.y) ** 2) +
((point2.z - point1.z) ** 2)
);
}
// instance members/methods
z
constructor(x,y,z) {
super(x,y); // <-- don't forget this line!
this.z = z;
}
toString() {
return `(${this.x},${this.y},${this.z})`;
}
}
Point2d.maxDistance; // 10
Point3d.maxDistance; // 10
As you can see, the static property maxDistance
we defined on Point2d
was inherited as a static property on Point3d
.
TIP: |
---|
Remember: any time you define a subclass constructor, you'll need to call super(..) in it, usually as the first statement. I find that all too easy to forget. |
Don't skip over the underlying JS behavior here. Just like method inheritance discussed earlier, the static "inheritance" is not a copying of these static properties/functions from base class to subclass; it's sharing via the [[Prototype]]
chain. Specifically, the constructor function Point3d()
has its [[Prototype]]
linkage changed by JS (from the default of Function.prototype
) to Point2d
, which is what allows Point3d.maxDistance
to delegate to Point2d.maxDistance
.
It's also interesting, perhaps only historically now, to note that static inheritance -- which was part of the original ES6 class
mechanism feature set! -- was one specific feature that went beyond "just syntax sugar". Static inheritance, as we see it illustrated here, was not possible to achieve/emulate in JS prior to ES6, in the old prototypal-class style of code. It's a special new behavior introduced only as of ES6.
Everything we've discussed so far as part of a class
definition is publicly visible/accessible, either as static properties/functions on the class, methods on the constructor's prototype
, or member properties on the instance.
But how do you store information that cannot be seen from outside the class? This was one of the most asked for features, and biggest complaints with JS's class
, up until it was finally addressed in ES2022.
class
now supports new syntax for declaring private fields (instance members) and private methods. In addition, private static properties/functions are possible.
Before we illustrate how to do class
privates, it bears contemplating why this is a helpful feature?
With closure-oriented design patterns (again, see the "Scope & Closures" book of this series), we automatically get "privacy" built-in. When you declare a variable inside a scope, it cannot be seen outside that scope. Period. Reducing the scope visibility of a declaration is helpful in preventing namespace collisions (identical variable names).
But it's even more important to ensure proper "defensive" design of software, the so called "Principle of Least Privilege" 1. POLP states that we should only expose a piece of information or capability in our software to the smallest surface area necessary.
Over-exposure opens our software up to several issues that complicate software security/maintenance, including another piece of code acting maliciously to do something our code didn't expect or intend. Moreover, there's the less critical but still as problematic concern of other parts of our software relying on (using) parts of our code that we should have reserved as hidden implementation detail. Once other code relies on our code's implementation details, we stop being able to refactor our code without potentially breaking other parts of the program.
So, in short, we should hide implementation details if they're not necessary to be exposed. In this sense, JS's class
system feels a bit too permissive in that everything defaults to being public. Class-private features are a welcomed addition to more proper software design.
All that said, I have to throw a bit of a damper on the class-private party.
I've suggested strongly that you should only use class
if you're going to really take advantage of most or all of what class-orientation gives you. Otherwise, you'd be better suited using other core pillar features of JS for organizing code, such as with the closure pattern.
One of the most important aspects of class-orientation is subclass inheritance, as we've seen illustrated numerous times so far in this chapter. Guess what happens to a private member/method in a base class, when it's extended by a subclass?
Private members/methods are private only to the class they're defined in, and are not inherited in any way by a subclass. Uh oh.
That might not seem like too big of a concern, until you start working with class
and private members/methods in real software. You might quickly run up against a situation where you need to access a private method, or more often even, just a private member, from the subclass, so that the subclass can extend/augment the behavior of the base class as desired. And you might scream in frustration pretty quickly once you realize this is not possible.
What comes next is inevitably an awkward decision: do you just go back to making it public, so the subclass can access it? Ugh. Or, worse, do you try to re-design the base class to contort the design of its members/methods, such that the lack of access is partially worked around. That often involves exhausting over-parameterization (with privates as default parameter values) of methods, and other such tricks. Double ugh.
There's not a particularly great answer here, to be honest. If you have experience with class-orientation in more traditional class languages like Java or C++, you're probably dubious as to why we don't have protected visibility in between public and private. That's exactly what protected is for: keeping something private to a class AND any of its subclasses. Those languages also have friend features, but that's beyond the scope of our discussion here.
Sadly, not only does JS not have protected visibility, it seems (even as useful as it is!) to be unlikely as a JS feature. It's been discussed in great detail for over a decade (before ES6 was even a thing), and there've been multiple proposals for it.
I shouldn't say it will never happen, because that's not solid ground to stake on in any software. But it's very unlikely, because it actually betrays the very pillar that class
is built on. If you are curious, or (more likely) certain that there's just got to be a way, I'll cover the incompatibility of protected visibility within JS's mechanisms in an appendix.
The point here is, as of now, JS has no protected visibility, and it won't any time soon. And protected visibility is actually, in practice, way more useful than private visibility.
So we return to the question: Why should you care to make any class
contents private?
If I'm being honest: maybe you shouldn't. Or maybe you should. That's up to you. Just go into it aware of the stumbling blocks.
You're excited to finally see the syntax for magical private visibility, right? Please don't shoot the messenger if you feel angered or sad at what you're about to see.
class Point2d {
// statics
static samePoint(point1,point2) {
return point1.#ID === point2.#ID;
}
// privates
#ID = null
#assignID() {
this.#ID = Math.round(Math.random() * 1e9);
}
// publics
x
y
constructor(x,y) {
this.#assignID();
this.x = x;
this.y = y;
}
}
var one = new Point2d(3,4);
var two = new Point2d(3,4);
Point2d.samePoint(one,two); // false
Point2d.samePoint(one,one); // true
No, JS didn't do the sensible thing and introduce a private
keyword like they did with static
. Instead, they introduced the #
. (insert lame joke about social-media millienials loving hashtags, or something)
TIP: |
---|
And yes, there's a million and one discussions about why not. I could spend chapters recounting the whole history, but honestly I just don't care to. I think this syntax is ugly, and many others do, too. And some love it! If you're in the latter camp, though I rarely do something like this, I'm just going to say: just accept it. It's too late for any more debate or pleading. |
The #whatever
syntax (including this.#whatever
form) is only valid inside class
bodies. It will throw syntax errors if used outside of a class
.
Unlike public fields/instance members, private fields/instance members must be declared in the class
body. You cannot add a private member to a class declaration dynamically while in the constructor method; this.#whatever = ..
type assignments only work if the #whatever
private field is declared in the class body. Moreover, though private fields can be re-assigned, they cannot be delete
d from an instance, the way a public field/class member can.
I warned earlier that subclassing with classes that have private members/methods can be a limiting trap. But that doesn't mean they cannot be used together.
Because "inheritance" in JS is sharing (through the [[Prototype]]
chain), if you invoke an inherited method in a subclass, and that inherited method in turn accesses/invokes privates in its host (base) class, this works fine:
class Point2d { /* .. */ }
class Point3d extends Point2d {
z
constructor(x,y,z) {
super(x,y);
this.z = z;
}
}
var one = new Point3d(3,4,5);
The super(x,y)
call in this constructor invokes the inherited base class constructor (Point2d(..)
), which itself accesses Point2d
's private method #assignID()
(see the earlier snippet). No exception is thrown, even though Point3d
cannot directly see or access the #ID
/ #assignID()
privates that are indeed stored on the instance (named one
here).
In fact, even the inherited static samePoint(..)
function will work from either Point3d
or Point2d
:
Point2d.samePoint(one,one); // true
Point3d.samePoint(one,one); // true
Actually, that shouldn't be that suprising, since:
Point2d.samePoint === Point3d.samePoint;
The inherited function reference is the exact same function as the base function reference; it's not some copy of the function. Because the function in question has no this
reference in it, no matter from where it's invoked, it should produce the same outcome.
It's still a shame though that Point3d
has no way to access/influence, or indeed even knowledge of, the #ID
/ #assignID()
privates from Point2d
:
class Point2d { /* .. */ }
class Point3d extends Point2d {
z
constructor(x,y,z) {
super(x,y);
this.z = z;
console.log(this.#ID); // will throw!
}
}
WARNING: |
---|
Notice that this snippet throws an early static syntax error at the time of defining the Point3d class, before ever even getting a chance to create an instance of the class. The same exception would be thrown if the reference was super.#ID instead of this.#ID . |
Keep in mind that only the class
itself knows about, and can therefore check for, such a private field/method.
You may want to check to see if a private field/method exists on an object instance. For example (as shown below), you may have a static function or method in a class, which receives an external object reference passed in. To check to see if the passed-in object reference is of this same class (and therefore has the same private members/methods in it), you basically need to do a "brand check" against the object.
Such a check could be rather convoluted, because if you access a private field that doesn't already exist on the object, you get a JS exception thrown, requiring ugly try..catch
logic.
But there's a cleaner approach, so called an "ergonomic brand check", using the in
keyword:
class Point2d {
// statics
static samePoint(point1,point2) {
// "ergonomic brand checks"
if (#ID in point1 && #ID in point2) {
return point1.#ID === point2.#ID;
}
return false;
}
// privates
#ID = null
#assignID() {
this.#ID = Math.round(Math.random() * 1e9);
}
// publics
x
y
constructor(x,y) {
this.#assignID();
this.x = x;
this.y = y;
}
}
var one = new Point2d(3,4);
var two = new Point2d(3,4);
Point2d.samePoint(one,two); // false
Point2d.samePoint(one,one); // true
The #privateField in someObject
check will not throw an exception if the field isn't found, so it's safe to use without try..catch
and use its simple boolean result.
Even though a member/method may be declared with private visibility, it can still be exfiltrated (extracted) from a class instance:
var id, func;
class Point2d {
// privates
#ID = null
#assignID() {
this.#ID = Math.round(Math.random() * 1e9);
}
// publics
x
y
constructor(x,y) {
this.#assignID();
this.x = x;
this.y = y;
// exfiltration
id = this.#ID;
func = this.#assignID;
}
}
var point = new Point2d(3,4);
id; // 7392851012 (...for example)
func; // function #assignID() { .. }
func.call(point,42);
func.call({},100);
// TypeError: Cannot write private member #ID to an
// object whose class did not declare it
The main concern here is to be careful when passing private methods as callbacks (or in any way exposing privates to other parts of the program). There's nothing stopping you from doing so, which can create a bit of an unintended privacy disclosure.
Static properties and functions can also use #
to be marked as private:
class Point2d {
static #errorMsg = "Out of bounds."
static #printError() {
console.log(`Error: ${this.#errorMsg}`);
}
// publics
x
y
constructor(x,y) {
if (x > 100 || y > 100) {
Point2d.#printError();
}
this.x = x;
this.y = y;
}
}
var one = new Point2d(30,400);
// Error: Out of bounds.
The #printError()
static private function here has a this
, but that's referencing the Point2d
class, not an instance. As such, the #errorMsg
and #printError()
are independent of instances and thus are best as statics. Moreover, there's no reason for them to be accessible outside the class, so they're marked private.
Remember: private statics are similarly not-inherited by subclasses just as private members/methods are not.
Recall that inherited methods, invoked from a subclass, have no trouble accessing (via this.#whatever
style references) any privates from their own base class:
class Point2d {
// ..
getID() {
return this.#ID;
}
// ..
}
class Point3d extends Point2d {
// ..
printID() {
console.log(`ID: ${this.getID()}`);
}
}
var point = new Point3d(3,4,5);
point.printID();
// ID: ..
That works just fine.
Unfortunately, and (to me) quite unexpectedly/inconsistently, the same is not true of private statics accessed from inherited public static functions:
class Point2d {
static #errorMsg = "Out of bounds."
static printError() {
console.log(`Error: ${this.#errorMsg}`);
}
// ..
}
class Point3d extends Point2d {
// ..
}
Point2d.printError();
// Error: Out of bounds.
Point3d.printError === Point2d.printError;
// true
Point3d.printError();
// TypeError: Cannot read private member #errorMsg
// from an object whose class did not declare it
The printError()
static is inherited (shared via [[Prototype]]
) from Point2d
to Point3d
just fine, which is why the function references are identical. Like the non-static snippet just above, you might have expected the Point3d.printError()
static invocation to resolve via the [[Prototype]]
chain to its original base class (Point2d
) location, thereby letting it access the base class's #errorMsg
static private.
But it fails, as shown by the last statement in that snippet. The reason it fails here, but not with the previous snippet, is a convoluted brain twister. I'm not going to dig into the why explanation here, frankly because it boils my blood to do so.
There's a fix, though. In the static function, instead of this.#errorMsg
, swap that for Point2d.#errorMsg
, and now it works:
class Point2d {
static #errorMsg = "Out of bounds."
static printError() {
// the fixed reference vvvvvv
console.log(`Error: ${Point2d.#errorMsg}`);
}
// ..
}
class Point3d extends Point2d {
// ..
}
Point2d.printError();
// Error: Out of bounds.
Point3d.printError();
// Error: Out of bounds. <-- phew, it works now!
If public static functions are being inherited, use the class name to access any private statics instead of using this.
references. Beware that gotcha!
OK, we've laid out a bunch of disparate class features. I want to wrap up this chapter by trying to illustrate a sampling of these capabilities in a single example that's a little less basic/contrived.
class CalendarItem {
static #UNSET = Symbol("unset")
static #isUnset(v) {
return v === this.#UNSET;
}
static #error(num) {
return this[`ERROR_${num}`];
}
static {
for (let [idx,msg] of [
"ID is already set.",
"ID is unset.",
"Don't instantiate 'CalendarItem' directly.",
].entries()) {
this[`ERROR_${(idx+1)*100}`] = msg;
}
}
static isSameItem(item1,item2) {
if (#ID in item1 && #ID in item2) {
return item1.#ID === item2.#ID;
}
else {
return false;
}
}
#ID = CalendarItem.#UNSET
#setID(id) {
if (CalendarItem.#isUnset(this.#ID)) {
this.#ID = id;
}
else {
throw new Error(CalendarItem.#error(100));
}
}
description = null
startDateTime = null
constructor() {
if (new.target !== CalendarItem) {
let id = Math.round(Math.random() * 1e9);
this.#setID(id);
}
else {
throw new Error(CalendarItem.#error(300));
}
}
getID() {
if (!CalendarItem.#isUnset(this.#ID)) {
return this.#ID;
}
else {
throw new Error(CalendarItem.#error(200));
}
}
getDateTimeStr() {
if (this.startDateTime instanceof Date) {
return this.startDateTime.toUTCString();
}
}
summary() {
console.log(`(${
this.getID()
}) ${
this.description
} at ${
this.getDateTimeStr()
}`);
}
}
class Reminder extends CalendarItem {
#complete = false; // <-- no ASI, semicolon needed
[Symbol.toStringTag] = "Reminder"
constructor(description,startDateTime) {
super();
this.description = description;
this.startDateTime = startDateTime;
}
isComplete() {
return !!this.#complete;
}
markComplete() {
this.#complete = true;
}
summary() {
if (this.isComplete()) {
console.log(`(${this.getID()}) Complete.`);
}
else {
super.summary();
}
}
}
class Meeting extends CalendarItem {
#getEndDateTimeStr() {
if (this.endDateTime instanceof Date) {
return this.endDateTime.toUTCString();
}
}
endDateTime = null; // <-- no ASI, semicolon needed
[Symbol.toStringTag] = "Meeting"
constructor(description,startDateTime,endDateTime) {
super();
this.description = description;
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
}
getDateTimeStr() {
return `${
super.getDateTimeStr()
} - ${
this.#getEndDateTimeStr()
}`;
}
}
Take some time to read and digest those class
definitions. Did you spot most of the class
features we talked about in this chapter?
NOTE: |
---|
One question you may have: why didn't I move the repeated logic of description and startDateTime setting from both subclass constructors into the single base constructor? This is a nuanced point, but it's not my intention that CalendarItem ever be directly instantiated; it's what in class-oriented terms we refer to as an "abstract class". That's why I'm using new.target to throw an error if the CalendarItem class is ever directly instantiated! So I don't want to imply by signature that the CalendarItem(..) constructor should ever be directly used. |
Let's now see these three classes in use:
var callMyParents = new Reminder(
"Call my parents to say hi",
new Date("July 7, 2022 11:00:00 UTC")
);
callMyParents.toString();
// [object Reminder]
callMyParents.summary();
// (586380912) Call my parents to say hi at
// Thu, 07 Jul 2022 11:00:00 GMT
callMyParents.markComplete();
callMyParents.summary();
// (586380912) Complete.
callMyParents instanceof Reminder;
// true
callMyParents instanceof CalendarItem;
// true
callMyParents instanceof Meeting;
// false
var interview = new Meeting(
"Job Interview: ABC Tech",
new Date("June 23, 2022 08:30:00 UTC"),
new Date("June 23, 2022 09:15:00 UTC")
);
interview.toString();
// [object Meeting]
interview.summary();
// (994337604) Job Interview: ABC Tech at Thu,
// 23 Jun 2022 08:30:00 GMT - Thu, 23 Jun 2022
// 09:15:00 GMT
interview instanceof Meeting;
// true
interview instanceof CalendarItem;
// true
interview instanceof Reminder;
// false
Reminder.isSameItem(callMyParents,callMyParents);
// true
Meeting.isSameItem(callMyParents,interview);
// false
Admittedly, some bits of this example are a little contrived. But honestly, I think pretty much all of this is plausible and reasonable usages of the various class
features.
By the way, there's probably a million different ways to structure the above code logic. I'm by no means claiming this is the right or best way to do so. As an exercise for the reader, try your hand and writing it yourself, and take note of things you did differently than my approach.
Footnotes
-
"Principle of Least Privilege", Wikipedia; https://en.wikipedia.org/wiki/Principle_of_least_privilege ; Accessed July 2022 ↩