Classes, Prototypes and this
Object-oriented JavaScript: classes with constructors, methods, getters and private fields, inheritance with extends, the prototype chain underneath, and what this refers to.
Classes
A
class
is a blueprint for objects that have the same shape and behavior.
The general ideas — instances, encapsulation, inheritance,
polymorphism — are explained in
Object-Oriented Concepts; this page shows how JavaScript does it. The
class syntax arrived in ES2015. Before that, the same
things were done directly with functions and prototypes, which is
still what happens underneath (see
The prototype chain).
Constructors and methods
The constructor is the special
method named constructor that runs when you create an
object with new ClassName(...). Its job is to set up
the new object's properties through this, which refers
to the object being built. The other methods in the class body are
shared by all instances.
class Meter {
constructor(id, city) {
this.id = id;
this.city = city;
this.readings = [];
}
addReading(kwh) {
this.readings.push(kwh);
}
usage() {
return this.readings.at(-1) - this.readings[0];
}
}
const delft = new Meter("MTR-0042", "Delft");
delft.addReading(412.5);
delft.addReading(415.8);
console.log(delft);
console.log(delft.usage().toFixed(1));
console.log(delft instanceof Meter, typeof Meter);
Meter { id: 'MTR-0042', city: 'Delft', readings: [ 412.5, 415.8 ] }
3.3
true function
A class without a constructor gets an empty one.
Calling a class without new is a
TypeError, and unlike function declarations, a class
cannot be used above the line that defines it: like
let, it is in the temporal dead zone until then (a
ReferenceError). Code inside a class body always runs
in strict mode.
Fields, getters and setters
Class fields declare instance properties in the class body,
with an optional starting value. They make the shape of an object
visible at a glance and are set before the constructor body runs (in
a subclass: as soon as super() returns).
A getter, written
get name() { … }, is a method that runs when you
read a property, so a computed value looks like a stored
one. A setter,
set name(value) { … }, runs when you assign to
the property, which makes it a natural place for validation. You use
them without parentheses.
class Tariff {
currency = "EUR"; // a class field
pricePerKwh = 0;
constructor(name, pricePerKwh) {
this.name = name;
this.pricePerKwh = pricePerKwh;
}
get pricePerMwh() { // computed on every read
return this.pricePerKwh * 1000;
}
set vatIncluded(price) { // runs on assignment
this.pricePerKwh = Math.round((price / 1.21) * 10000) / 10000;
}
}
const standard = new Tariff("Standard", 0.25);
console.log(standard.pricePerMwh); // no parentheses
standard.vatIncluded = 0.3025;
console.log(standard.pricePerKwh, standard.currency);
250 0.25 EUR
A getter without a setter is read-only: assigning to it is silently
ignored in sloppy code and a TypeError in strict code
(including class bodies and modules). Getters and setters also work
in plain object literals.
Private fields and methods
A private field is a field
whose name starts with #. It can only be used by code
inside the class body; anywhere else, mentioning it is a
SyntaxError before the program even starts. This is
real
encapsulation, enforced by the language (ES2022). Methods can be private too:
#validate().
class Meter {
#readings = [];
constructor(id) {
this.id = id;
}
#check(kwh) {
const last = this.#readings.at(-1) ?? 0;
if (!(kwh >= last)) {
throw new RangeError(`Reading ${kwh} is lower than ${last}`);
}
}
addReading(kwh) {
this.#check(kwh);
this.#readings.push(kwh);
}
get latest() {
return this.#readings.at(-1);
}
}
const meter = new Meter("MTR-0042");
meter.addReading(412.5);
meter.addReading(415.8);
console.log(meter.latest);
console.log(meter.readings); // no public property of that name
try {
meter.addReading(400);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
415.8 undefined RangeError: Reading 400 is lower than 415.8
Outside code can only change the readings through
addReading, so the rule "readings never go down" holds
for every Meter. Trying to reach the field from outside
does not even compile:
class Meter {
#readings = [412.5];
}
const meter = new Meter();
console.log(meter.#readings);
SyntaxError: Private field '#readings' must be declared in an enclosing class
Static members
A static method belongs to the
class itself instead of to its instances: you call it as
ClassName.method(). Typical uses are
factory methods that build an instance from another format,
and helpers that do not need any instance data. Static fields work
the same way. You know many built-in static methods already:
Array.isArray(), Object.keys(),
Math.max().
class Reading {
static count = 0;
constructor(meterId, kwh) {
this.meterId = meterId;
this.kwh = kwh;
Reading.count += 1;
}
static fromCsv(line) { // factory: text → instance
const [meterId, kwh] = line.split(";");
return new Reading(meterId, Number(kwh));
}
}
const r = Reading.fromCsv("MTR-0042;415.8");
console.log(r);
console.log(Reading.count);
console.log(typeof r.fromCsv); // not available on instances
Reading { meterId: 'MTR-0042', kwh: 415.8 }
1
undefined
Inheritance with extends and super
class Child extends Parent creates a subclass that
inherits
all methods of the parent. The keyword
super gives access to the
parent: in the constructor, super(...) runs the
parent's constructor, and in a method,
super.method() calls the parent's version of a method
the subclass has overridden.
class Meter {
constructor(id, city) {
this.id = id;
this.city = city;
}
describe() {
return `${this.id} (${this.city})`;
}
}
class SmartMeter extends Meter {
constructor(id, city, intervalMinutes) {
super(id, city); // must come before using this
this.intervalMinutes = intervalMinutes;
}
describe() { // overrides Meter's describe
return `${super.describe()}, reads every ${this.intervalMinutes} min`;
}
}
const meters = [
new Meter("MTR-0007", "Assen"),
new SmartMeter("MTR-0042", "Delft", 15),
];
for (const m of meters) {
console.log(m.describe()); // each object uses its own version
}
console.log(meters[1] instanceof SmartMeter, meters[1] instanceof Meter);
MTR-0007 (Assen) MTR-0042 (Delft), reads every 15 min true true
In a subclass constructor you must call
super(...) before you touch this;
otherwise JavaScript throws a ReferenceError. If the
subclass has no constructor, one that passes all arguments to
super is added for you. You can also extend built-in
classes; extending Error for your own error types is
shown in Error Handling.
The prototype chain
JavaScript is
prototype-based: every object has a hidden link to another object, its
prototype, or to null. When you read a
property that the object does not have itself, JavaScript looks at
the prototype, then at the prototype's prototype, and so on. That
series of links is the
prototype chain. The search
stops at the first match, or returns undefined when it
reaches null.
A class is a function, and the methods you write in its body are
stored once, on the object ClassName.prototype. Every
instance gets that object as its prototype. extends
links one class's prototype object to the parent's.
class Meter {
describe() { return "meter"; }
}
class SmartMeter extends Meter {
describe() { return "smart meter"; }
}
const m = new SmartMeter();
console.log(Object.getPrototypeOf(m) === SmartMeter.prototype);
console.log(Object.getPrototypeOf(SmartMeter.prototype) === Meter.prototype);
console.log(Object.hasOwn(m, "describe"), "describe" in m);
// Walk the chain
let obj = m;
while (obj !== null) {
console.log(obj === m ? "m" : obj.constructor.name + ".prototype");
obj = Object.getPrototypeOf(obj);
}
true true false true m SmartMeter.prototype Meter.prototype Object.prototype
The prototype chain is also why instanceof works: it
checks whether ClassName.prototype appears anywhere in
the object's chain. And it is why plain objects have methods such as
toString that you never wrote: they come from
Object.prototype. Object.create(proto)
makes an object with a chosen prototype without any class.
What this refers to
this is a keyword whose
value is decided each time a function is called, not when
it is written. The rules for normal (non-arrow) functions:
-
Method call,
obj.method():thisisobj, the object before the dot. -
Constructor call,
new ClassName():thisis the new object. -
Plain call,
fn():thisisundefinedin strict mode (class bodies, modules, files with"use strict") and the global object in old sloppy code. -
Explicit:
fn.call(obj, …)andfn.apply(obj, […])callfnwiththisset toobj;fn.bind(obj)returns a new function whosethisis fixed toobj.
Arrow functions are the exception: they have no
this of their own and use the this of the
code around them (see Functions).
Losing this
The classic mistake follows from the first and third rule. When you
take a method out of its object — to store it in a variable or pass
it as a callback — the call no longer has an object before the dot,
so this is undefined:
class TicketCounter {
count = 0;
add() {
this.count += 1;
return this.count;
}
}
const counter = new TicketCounter();
console.log(counter.add()); // method call: this is counter
const add = counter.add; // just the function, no object
console.log(add());
1 TypeError: Cannot read properties of undefined (reading 'count')
The same happens with
["INC-1", "INC-2"].forEach(counter.add), or when a
method is passed to an event listener or setTimeout.
There are three common fixes:
class TicketCounter {
count = 0;
add() {
this.count += 1;
}
// Fix 3: a class field holding an arrow function keeps this
addArrow = () => {
this.count += 1;
};
}
const counter = new TicketCounter();
const tickets = ["INC-20481", "INC-20482"];
tickets.forEach(() => counter.add()); // Fix 1: wrap the call in an arrow
tickets.forEach(counter.add.bind(counter)); // Fix 2: bind this
tickets.forEach(counter.addArrow); // Fix 3
console.log(counter.count);
6
Arrow functions and this
Because arrow functions use the surrounding this, they
are the right choice for callbacks inside methods — and the
wrong choice as methods in an object literal, where the
surrounding this is not the object.
"use strict";
const station = {
name: "Delft-Noord",
feeders: ["F1", "F2"],
list() {
// the arrow callback sees list's this, which is station
return this.feeders.map((f) => `${this.name}/${f}`);
},
broken: () => typeof this, // arrow as method: this is not station
};
console.log(station.list());
console.log(station.broken());
[ 'Delft-Noord/F1', 'Delft-Noord/F2' ] object
In a Node.js CommonJS file, the top-level this is the
(empty) module.exports object, hence
"object" above; in a browser script it is
window, and in an ES module it is
undefined. Either way, it is not station.