Chapter 9: JavaScript Classes
Classes in JavaScript provide a cleaner, more structured way to work with objects and inheritance. They build on the existing prototype system, but present it in a syntax that is easier to read and maintain. A class defines the blueprint for creating objects, bundling together data (fields) and behavior (methods) within a single structure.
Although class syntax was introduced in ECMAScript 2015 (ES6), it does not replace JavaScript's prototype-based system underneath. Instead, it offers syntactic sugar that allows you to declare constructors, inheritance chains, and methods in a way that will feel familiar to programmers coming from languages such as Java, C++, or Python.
This chapter introduces the full class syntax and demonstrates how it integrates with existing object behavior. You will learn how to declare and instantiate classes, define fields and accessors, extend other classes, and use static members for shared functionality. Together these features allow you to design clear, reusable structures for your programs.
Class syntax and constructors
The class keyword defines a template for creating objects with shared structure and behavior. Inside a class body, you can declare a constructor() method, which runs automatically whenever a new instance is created using the new keyword. The constructor is typically used to initialize instance fields and set up the object’s initial state.
Here is a simple example that defines a class and creates an instance of it:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(`Hello, my name is ${this.name}`);
}
}
const robin = new Person('Robin', 42);
robin.greet(); // Hello, my name is Robin
The constructor() is special: each class can have only one. If you attempt to declare multiple constructors, JavaScript will throw a syntax error. However, you can use default parameters or conditional logic to handle different initialization scenarios within the same constructor.
constructor(), JavaScript automatically provides an empty one that takes no arguments and simply calls super() if the class extends another.
Class declarations are not hoisted like functions. You must define the class before you can use it, or you will encounter a ReferenceError. This ensures that class definitions behave predictably and cannot be accessed before declaration.
// This will cause an error
const p = new Person('Alex');
class Person {
constructor(name) {
this.name = name;
}
}
Classes can also be defined as expressions and assigned to variables. This is known as a class expression and can be useful when creating classes dynamically or conditionally:
const Animal = class {
constructor(type) {
this.type = type;
}
speak() {
console.log(`${this.type} makes a sound`);
}
};
const dog = new Animal('Dog');
dog.speak(); // Dog makes a sound
Whether you use a class declaration or expression, both forms are functionally equivalent. The difference is mainly one of style and flexibility.
Fields, getters, and setters
Classes can define not only methods, but also fields (properties declared directly inside the class body) as well as getters and setters that control how values are accessed and updated. These features help you encapsulate data and provide a clear interface for working with class instances.
Defining instance fields
Instance fields belong to each object created from a class. They can be declared directly in the class body, outside of the constructor. This makes code shorter and clearer, especially when combined with initial default values.
class Counter {
count = 0;
increment() {
this.count++;
}
}
const c = new Counter();
c.increment();
console.log(c.count); // 1
In older JavaScript versions, you had to assign such properties manually inside the constructor. Field declarations now provide a concise, modern alternative while still allowing you to override or extend behavior as needed.
this safely or be assigned computed values.
Private fields
Private fields are declared using a leading hash sign (#) and are accessible only within the class where they are defined. They cannot be read, written, or deleted from outside the class.
class User {
#password;
constructor(name, password) {
this.name = name;
this.#password = password;
}
checkPassword(value) {
return value === this.#password;
}
}
const u = new User('Alice', 'secret');
console.log(u.checkPassword('secret')); // true
console.log(u.#password); // SyntaxError
Getters and setters
Getters and setters are special methods that define how a property is read or written. They allow you to add logic around property access, such as validation or computed values, while still using normal property syntax.
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height;
}
set area(value) {
throw new Error('Area is read-only');
}
}
const r = new Rectangle(4, 5);
console.log(r.area); // 20
r.area = 30; // Error: Area is read-only
When a getter or setter is defined, it takes the place of a normal property with the same name. This makes it possible to design flexible APIs that act like fields but are backed by logic.
Combining fields and accessors
Fields, getters, and setters can be freely mixed within the same class. A typical pattern uses private fields for data storage and public accessors for controlled access:
class Product {
#price = 0;
get price() {
return this.#price;
}
set price(value) {
if (value < 0) throw new Error('Price must be positive');
this.#price = value;
}
}
const item = new Product();
item.price = 9.99;
console.log(item.price); // 9.99
This pattern provides a clean balance between encapsulation and ease of use. The external interface appears simple, while internal logic remains safely protected inside the class definition.
Inheritance with extends and super
Inheritance allows one class to build upon another, reusing and extending its behavior. In JavaScript, this is done using the extends keyword to create a subclass, and the super keyword to call the constructor or methods of the parent class. Together, they provide a powerful and expressive way to organize related classes in a hierarchy.
Creating a subclass with extends
The extends keyword establishes a prototype chain between the subclass and its parent. This means that all methods and fields of the parent class are available to instances of the subclass, unless explicitly overridden.
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound`);
}
}
class Dog extends Animal {
speak() {
console.log(`${this.name} barks`);
}
}
const d = new Dog('Rex');
d.speak(); // Rex barks
In this example, Dog inherits from Animal. It overrides the speak() method but still retains access to other methods defined on Animal.
Using super() in constructors
If a subclass defines its own constructor(), it must call super() before accessing this. The super() call invokes the parent class’s constructor, allowing inherited properties to initialize properly.
class Vehicle {
constructor(type) {
this.type = type;
}
}
class Car extends Vehicle {
constructor(type, brand) {
super(type); // must call before using 'this'
this.brand = brand;
}
info() {
console.log(`${this.brand} is a ${this.type}`);
}
}
const car = new Car('sedan', 'Toyota');
car.info(); // Toyota is a sedan
super() before referencing this in a subclass constructor. Failing to do so results in a ReferenceError.
Calling parent methods with super
The super keyword can also be used within methods to access the parent class’s implementation of a function. This is useful when you want to extend the parent behavior rather than completely replace it.
class Shape {
describe() {
console.log('This is a shape');
}
}
class Circle extends Shape {
describe() {
super.describe(); // call parent method
console.log('Specifically, a circle');
}
}
const c = new Circle();
c.describe();
// This is a shape
// Specifically, a circle
This pattern allows a subclass to enhance or modify its inherited behavior while preserving the original functionality of its parent class.
Inheritance and the prototype chain
Under the surface, extends sets up the subclass prototype to point to its parent, maintaining JavaScript’s long-standing prototype-based design. Every class instance inherits from its class’s prototype, and that prototype in turn inherits from its parent’s prototype, forming a chain.
console.log(Object.getPrototypeOf(Car.prototype) === Vehicle.prototype); // true
Through this prototype chain, subclasses gain access to methods and properties defined higher up in the hierarchy. The extends keyword simply automates what could otherwise be done manually using Object.create() and explicit prototype assignment.
Static fields and methods
Static fields and methods belong to the class itself rather than to its instances. They are defined using the static keyword and are accessed directly through the class name, not through objects created from the class. This makes them ideal for utility functions, shared constants, or operations that apply to the class as a whole.
Defining static methods
A static method is declared by prefixing the method name with static. It can be called directly on the class, but not on instances of that class.
class MathUtils {
static square(x) {
return x * x;
}
static cube(x) {
return x * x * x;
}
}
console.log(MathUtils.square(3)); // 9
console.log(MathUtils.cube(2)); // 8
In this example, the methods square() and cube() are accessed on MathUtils itself. Creating an instance of MathUtils would serve no purpose because the behavior does not depend on any object state.
Static fields
Static fields work in a similar way, holding data that is shared across all instances of a class. They are declared using the static keyword as well.
class Counter {
static total = 0;
constructor() {
Counter.total++;
}
}
const a = new Counter();
const b = new Counter();
console.log(Counter.total); // 2
Each time a new Counter instance is created, the shared total field is incremented. This allows classes to track global counts or maintain shared state easily and cleanly.
Static inheritance
Static members can also be accessed or overridden by subclasses. The super keyword can be used within static methods to call static members of the parent class.
class Shape {
static describe() {
return 'A general shape';
}
}
class Circle extends Shape {
static describe() {
return super.describe() + ', specifically a circle';
}
}
console.log(Shape.describe()); // A general shape
console.log(Circle.describe()); // A general shape, specifically a circle
This pattern lets subclasses modify or extend shared static behavior while still reusing logic from their parent classes.
Private static fields and methods
Just like instance fields, static members can also be made private by prefixing them with #. These can only be accessed from within the class definition itself.
class Config {
static #secret = 'xyz123';
static getSecret() {
return Config.#secret;
}
}
console.log(Config.getSecret()); // xyz123
console.log(Config.#secret); // SyntaxError
Private static members help maintain internal consistency and prevent external access to data or functions meant for internal use only.
Together, static fields and methods provide a clean way to define class-level data and functionality. They complement instance-based features by separating what belongs to the whole class from what belongs to individual objects.
Private fields and methods
Private fields and methods allow you to strictly control access to internal data and helper functions within a class. They are declared by prefixing their names with a hash sign (#), and cannot be accessed from outside the class where they are defined. This feature adds true encapsulation to JavaScript’s class model, ensuring internal details stay hidden and protected.
Declaring private fields
A private field is created by placing # before its name inside the class body. It can only be accessed by methods or constructors defined within the same class.
class Account {
#balance = 0;
deposit(amount) {
if (amount > 0) this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const a = new Account();
a.deposit(100);
console.log(a.getBalance()); // 100
console.log(a.#balance); // SyntaxError
Here, #balance cannot be read or written directly from outside the class. Any attempt to do so results in a syntax error during parsing, not at runtime.
Object.keys() or for...in loops, and cannot be dynamically created or deleted.
Private methods
Private methods are declared in the same way, using a leading #. They can be called only from other methods within the same class and are invisible to subclasses and external code.
class PasswordValidator {
#isStrong(password) {
return password.length >= 8 && /\d/.test(password);
}
validate(password) {
return this.#isStrong(password)
? 'Valid password'
: 'Too weak';
}
}
const v = new PasswordValidator();
console.log(v.validate('abc123')); // Too weak
console.log(v.validate('abc12345')); // Valid password
This approach lets you hide supporting logic that should not be part of the public interface, keeping your API clean and focused on what matters to users of the class.
Access from subclasses
Private fields and methods are not inherited or accessible by subclasses. Each class that declares them creates its own distinct private storage. If a subclass defines a private field with the same name, it is treated as a separate member.
class Base {
#secret = 'hidden';
reveal() {
console.log(this.#secret);
}
}
class Derived extends Base {
#secret = 'new';
revealDerived() {
console.log(this.#secret);
}
}
const d = new Derived();
d.reveal(); // hidden
d.revealDerived(); // new
In this example, the #secret field of Derived does not override the one in Base. Both exist independently, even though they share the same name.
Private static members
You can also combine the static and # keywords to define private static members that belong to the class itself rather than instances. They are useful for storing internal configuration or shared state that should not be exposed.
class ConfigManager {
static #apiKey = 'abc123';
static getKey() {
return ConfigManager.#apiKey;
}
}
console.log(ConfigManager.getKey()); // abc123
console.log(ConfigManager.#apiKey); // SyntaxError
Private static fields and methods remain hidden from both instances and subclasses, providing strict control over sensitive data and helper logic.
#) are lexically scoped and cannot be created dynamically. You must declare them explicitly in the class body before they can be used.
Private fields and methods bring JavaScript classes in line with the encapsulation features long found in other object-oriented languages. They make it easier to design robust, maintainable APIs with clear boundaries between internal and external behavior.