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.

The prototype chain of a SmartMeter instance. The object m holds its own properties id, city and intervalMinutes. Its prototype is SmartMeter.prototype with the method describe. Its prototype is Meter.prototype with describe. Its prototype is Object.prototype with toString and hasOwnProperty. Its prototype is null, where the lookup ends. m id, city, intervalMinutes SmartMeter.prototype constructor, describe Meter.prototype constructor, describe Object.prototype toString, hasOwnProperty, … null m.describe() → found on SmartMeter.prototype m.toString() → found three links up, on Object.prototype m.serial → not found: undefined at null
Each arrow is a prototype link. A property lookup starts at the object and follows the arrows until it finds the name.
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(): this is obj, the object before the dot.
  • Constructor call, new ClassName(): this is the new object.
  • Plain call, fn(): this is undefined in strict mode (class bodies, modules, files with "use strict") and the global object in old sloppy code.
  • Explicit: fn.call(obj, …) and fn.apply(obj, […]) call fn with this set to obj; fn.bind(obj) returns a new function whose this is fixed to obj.

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.