Introduction to classes

Object-oriented programming

In previous chapters we learned that JavaScript is object oriented, like most of the most widely used programming languages are. Object-oriented programming (OOP) can be prototype-based or class-based. We have seen that JavaScript is a prototype-based language: the properties of an object are specified by its own properties and the properties of its prototype. However, to meet the popularity of class-based OOP, classes were added to JavaScript, which allows developers to create inheritance of properties more in line with class-based languages such as Java. Nevertheless, JavaScript remains prototype-based and, unlike in class-based languages, classes and objects are not fundamentally distinct from each other in JavaScript.

In class-based OOP, objects are instances of classes. A class is a template for creating objects "of a certain kind". The creation of an object from a class is called instantiation or construction. An object created from a class may be called a class object, class instance or simply instance.

In a non-programming context you could think, for example, of "actor" as a class and Jack Nicholson as an instance of that class. The definition of a class lists properties and methods that all instances of that class have in common. When we define the class "actor" we may say that all actors have properties in common, like a name, a height, number of Academy Awards, etc. and abilities (methods) in common like speaking, playing a death scene, etc. The values of those properties may be different for each instance and are initialized in a new instance at instantiation.

Class-based OOP is the traditional or classical type of OOP. When people mention just OOP, they often mean class-based OOP without considering prototype-based OOP.

Classes vs. constructor functions

In JavaScript, classes are normal values (of data type "function"), not fundamentally different from other values in JavaScirpt. They are similar to, but not the same as constructor functions. Just like with constructor functions, new objects (instances) are created through the new operator. However, unlike (constructor) functions, classes cannot be called directly. Other distinct differences to regular functions are that all code inside a class body is automatically in strict mode and that class declarations are hoisted, but with the temporal dead zone restriction, alike variables declared with let and const.


// constructor function:
function User(name, avatar, nComments) {
  "use strict";
  if (!new.target) { throw new TypeError("this constructor function must be invoked with 'new'"); }
  this.name = name;
  this.avatar = avatar;
  this.nComments = nComments;
  this.greet = function() {
    console.log('Hello ' + this.avatar);
  }
};

const user1 = new User('John Doe', 'John1986', 321);
const user2 = new User('Jane Roe', 'Marge Bouvier', 4022);
user2.greet(); // logs: "Hello Marge Bouvier"

const user3 = User('Jack Sparrow', 'Pirate of the Caribbean', 0); // TypeError: this constructor function must be invoked with 'new'

// Class:
class User {  
  constructor(name, avatar, nComments) {
    this.name = name;
    this.avatar = avatar;
    this.nComments = nComments;
	this.greet = function() {
      console.log('Hello ' + this.avatar);
    };
  }
};

const user1 = new User('John Doe', 'John1986', 321);
const user2 = new User('Jane Roe', 'Marge Bouvier', 4022);
user2.greet(); // logs: "Hello Marge Bouvier"

console.log(user1.greet === user2.greet); // logs: false
const user3 = User('Jack Sparrow', 'Pirate of the Caribbean', 0); // TypeError: class constructors must be invoked with 'new'

BTW. new.target (used above in the constructor function) detects whether a function or constructor was called using the new operator.

As with constructor functions, the name of the class must, by convention, have an initial letter in upper case.

As we have seen before, in JavaScript all objects have inherited a constructor property that references the constructor function that the object was created from. This constructor property also references the class that the object was created from, if it was created from a class.


class MyClass {}
const  myInstance = new MyClass();
console.log(myInstance.constructor); // logs: class MyClass {}

Similar to functions, a class can be defined by using a class declaration (as used in the example above) or by using a class expression. Like with function expressions, the class in a class expressions may be anonymous, or have a name that may be different from the variable that it's assigned to. The class name in a class expression can only be used within the class's body.


// Anonymous class expression:
const User = class {
  // Class body...
};

// Named class expression:
const User = class UserClassName {
  // Class body...
  // Here User and UserClassName point to the same class.
};
new UserClassName(); // ReferenceError: UserClassName is not defined

Class body

The class body is where class members are defined. The class members are (all optional):

Methods, fields and accessors can be static or instance (default) and they can be private or public (default).

The default public instance methods, fields and accessors will be covered first. The meaning of "static" and "private" will be discussed later.

Constructor method

In the next example, when new MyClass() is invoked, the class's constructor method creates a new object, i.e., a new MyClass instance. The instance has a values property. Each time you call the class (using new), a different instance is created.


const MyClass = class {
  constructor(...values) {
    this.values = values;
  }
};

const myClassInstance1 = new MyClass(1, 2, 3, 4);
console.log(myClassInstance1.values); // logs: [ 1, 2, 3, 4 ]

As you can see, the value of this points to an instance to be newly created.

Be aware that a constructor that returns an object makes this returned object the result of the class invocation (using new). When the return value is not an object but a primitive, it is ignored altogether (which is consistent with constructor functions). Generally it is best practice to never return any value from a constructor.


class MyClass {
  constructor(a, b) {
    this.values = [a, b];
    return {p: 1};
  }
}

const myInstance = new MyClass(2, 3);
console.log(myInstance); // { p: 1 }
console.log(myInstance.values); // undefined

More than one occurrence of a constructor method in a class will throw a SyntaxError. A constructor method may be omitted altogether. In that case instantiation uses a default constructor. The default constructor is a function with an empty body (unless the class is a derived class, see later).


class MyClass {}
/*
// Is the same as:
class MyClass {
  constructor() {}
}
*/

const myInstance = new MyClass();
console.log(myInstance); // logs: {}

Classes with only a constructor method, as presented in the examples above, are not much different from their constructor function counterparts. They even involve a little bit more code then their constructor function alternatives. However, as will be unfolded in the rest of this chapter, classes offer a way to organize code to create objects in a structured object-oriented way.

Public instance methods

Earlier we learned that the prototype of a constructed object is an anonymous object designated by the construction function's property named prototype (MyConstructorFunction.prototype). In the first example on this page, the construction function creates a separate method greet for each object instance, while all methods do the same thing. If we make the greet method a method of the construction function's property prototype, then this will create one method that is shared by all object instances that are created by the construction function. This is much better than creating a new separate method every time a object instances is created.


// constructor function:
function User(name, avatar, nComments) {
  this.name = name;
  this.avatar = avatar;
  this.nComments = nComments;
};

User.prototype.greet = function() {
  console.log('Hello ' + this.avatar);
};

const user1 = new User('John Doe', 'John1986', 321);
const user2 = new User('Jane Roe', 'Marge Bouvier', 4022);
user2.greet(); // logs: "Hello Marge Bouvier"

console.log(user1.greet === user2.greet); // logs: true

PS. Note that even though there is only one method greet, its behavior differs when different instances call it, changing the value of this.

When using a class we can include the greet method in the class body. This basically does the same as using a construction function's property prototype as shown above.


// Class:
class User {
  constructor(name, avatar, nComments) {
    this.name = name;
    this.avatar = avatar;
    this.nComments = nComments;
  }
  greet() {
    console.log('Hello ' + this.avatar);
  }  
};

const user1 = new User('John Doe', 'John1986', 321);
const user2 = new User('Jane Roe', 'Marge Bouvier', 4022);
user2.greet(); // logs: "Hello Marge Bouvier"

console.log(user1.greet === user2.greet); // logs: true
console.log(user1.greet === User.prototype.greet); // logs: true
console.log(Object.getPrototypeOf(user1) === User.prototype); //logs: true

Note that public instance methods inside the class body are declared using the same method shorthand syntax as used in an object literal (no function keyword).

Another advantage of methods not being the instance's own properties may be that instance methods are often not supposed to be included when using methods like Object.assign(). Also when using a for...in loop we usually do not want instance methods to be included. However, a for...in loop also traverses inherited properties. Fortunately, unlike with constructor function's prototype methods, instance methods are set to non-enumerable by default.


class MyClass {
  constructor(a, b) {
    this.values = [a, b];
  }
  add() {
    return this.values[0] + this.values[1];    
  }  
};

const myInstance = new MyClass(4, 6);
console.log(myInstance.add()); // logs: 10

for (let propertyName in myInstance) {
   console.log(propertyName + ": " + myInstance[propertyName]);
}
// logs: values: 4,6

console.log(Object.hasOwn(myInstance, 'add')); // logs: false
console.log(Object.hasOwn(MyClass.prototype, 'add')); // logs: true
console.log(MyClass.prototype.propertyIsEnumerable('add')); // logs: false

Public instance fields

Properties that are not added to MyClass.prototype but added directly as own properties to a class instance, do not have to be declared within the constructor. They can also be declared and initialized like a regular variable (but without var, let or const) directly within the class body and without using this. However, they cannot use arguments that are passed in an instantiation. Public instance fields are usually designed to be independent of the constructor's parameters.

Unlike public instance methods, public instance fields are enumerable by default. Unlike public instance fields, public instance methods are set as methods of MyClass.prototype.


class User {
  // Public instance fields:
  platform = "www.myblog.com";  
  name;
  avatar;
  nComments;

  // constructor:  
  constructor(name, avatar, nComments) {
    this.name = name;
    this.avatar = avatar;
    this.nComments = nComments;
  }

  // Public instance method:  
  greet() {
    console.log('Hello ' + this.avatar);
  }  
};

const user1 = new User('John Doe', 'John1986', 321);
const user2 = new User('Jane Roe', 'Marge Bouvier', 4022);

console.log(user1.platform); // logs: "www.myblog.com"
console.log(Object.hasOwn(user2, 'platform')); // logs: true
console.log(user1.platform === User.prototype.platform); // logs: false

The name, avatar and nComments field declarations may be omitted, but it is good practice to list all properties explicitly as fields to make code more readable and make it easier to see which properties are part of the class. By the way, it is not possible to separate the fields by commas in one statement, like you can do with variable declarations.

Keyword this can be used in a field and refers to the class instance under construction. Like properties in an object literal, field names may be computed.


const myVariable = "foo";

class ClassWithFields {
  undefinedField;
  myField = "instance field";
  myOtherField = this.myField;  
  [`${myVariable}Bar`] = "computed name field";
}

const myInstance = new ClassWithFields();
console.log(myInstance.undefinedField); // undefined
console.log(myInstance.myOtherField); // "instance field"
console.log(myInstance.fooBar); // "computed name field"

A function can be assigned to a field, making it a method. But such a method shares the same disadvantage with methods created in the constructor method: a separate own method for each instance instead of sharing the same method by all instances via MyClass.prototype.


class MyClass {
  value;

  constructor(value) {
    this.value = value;
  }
  
  // callable instance field:
  method1 = () => {
    return this.value;
  }
  
  // instance method:
  method2() {
    return this.value;
  }
}

const myInstance1 = new MyClass(313);
const myInstance2 = new MyClass(131);

console.log(myInstance1.method1()); // logs: 313
console.log(myInstance2.method1()); // logs: 131

console.log(myInstance1.method1 === myInstance2.method1); // logs: false
console.log(myInstance1.method2 === myInstance2.method2); // logs: true

Accessors

Like accessors (getters and setters) on objects, we can also use accessor functions on classes in the same way. This way we can use functions to set and get a property value, while the property itself cannot hold an actual value. In fact, the property itself does not really exist. The accessor functions create a "pseudo-property"[more info], as if the property is a regular property.

If a property only has a getter but no setter, it will be effectively read-only. In strict mode, assigning a value to a property with only a getter will throw a TypeError. In non-strict mode, the assignment is silently ignored.


class Person {
  name;
  #gender;
   
  constructor(name) {
    this.name = name;
  }

  get gender() {
    return this.#gender;
  }
  
  set gender(s) {
    if (!(["x", "X", "f", "F", "m", "M"].includes(s))) {
      console.log("Only 'x', 'f' or 'm' are allowed (case-insensitive)");
	  return;
	}
	this.#gender = s;	
  }
}

const janeDoe = new Person("Jane Doe");

console.log(janeDoe.gender); // logs: undefined
janeDoe.gender = "F";
console.log(janeDoe.gender); // logs: "F"

console.log(Object.hasOwn(janeDoe, 'gender')); // logs: false
console.log(Object.hasOwn(Person.prototype, 'gender')); // logs: true

BTW: #gender is a private field, which will be explained below. It is not the pseudo-property gender.

Note that the pseudo-properties defined by a setter and/or getter do not become own properties of an instance, just like with public instance methods and unlike with public instance fields. Properties defined by a setter and/or getter are set to the MyClass.prototype.

An alternative approach, without getter and setter, is to use public instance methods setGender() and getGender():


class Person {
  name;
  #gender;
   
  constructor(name) {
    this.name = name;
  }
  
  getGender() {
    return this.#gender;
  }  
  
  setGender(s) {
    if (!(["x", "X", "f", "F", "m", "M"].includes(s))) {
      console.log("Only 'x', 'f' or 'm' are allowed (case-insensitive)");
	  return;
	}
	this.#gender = s;	
  }
}

const janeDoe = new Person("Jane Doe");

console.log(janeDoe.getGender()); // logs: undefined
janeDoe.setGender("F");
console.log(janeDoe.getGender()); // logs: "F"

janeDoe.setGender("female"); // logs: "Only 'x', 'f' or 'm' are allowed (case-insensitive)"
console.log(janeDoe.getGender()); // logs: "F"

// console.log(janeDoe.#gender); // SyntaxError: reference to undeclared private field or method #gender

Note that we used #gender, prefixed with a hash #, for the private field where a private property in a regular object is prefixed with an underscore _, as we have discussed in "accessors on objects". The field is made private to prevent something like janeDoe.gender = "female" bypass the setGender() method.

For regular objects JavaScript does not enforce inner access only of private properties, but private fields (and methods), prefixed with #, in classes is a special syntax and accessing private fields outside the class throws a SyntaxError. In the next chapter more about private fields and methods.

Using accessor functions (getter and setter) is a little more elegant than using instance methods because the regular property accessors (dot notation or the bracket notation) can be used, although methods accept (multiple) arguments while accessor functions do not.