JavaScript Classes

A JavaScript class is a template for creating objects that bundle related data (fields) and behavior (methods) together. Introduced in ES6 (2015), classes did not add a new object model to the language — they are syntactic sugar over JavaScript’s existing prototype-based inheritance system, giving you a cleaner, more familiar syntax for the same underlying mechanism. Classes matter because most real-world JavaScript code, from React components to Node.js services, organizes state and behavior this way, and understanding what a class actually compiles down to will save you from confusing bugs involving this, prototypes, and inheritance.

Overview: How Classes Work

Under the surface, a class declaration creates a special kind of function. If you check typeof MyClass, you get 'function'. The constructor method inside the class body becomes the body of that function, and every other method you define (outside of fields) is placed on MyClass.prototype, not on each individual instance. This is important: when you call instance.method(), JavaScript does not find method on the instance itself — it walks up the prototype chain via the internal [[Prototype]] link until it finds method on the class’s prototype object. All instances share the exact same function in memory, which is far more efficient than attaching a fresh copy of every method to every object.

Class bodies always execute in strict mode, even if the surrounding file is not in strict mode. This means silent mistakes that would be ignored in old-style sloppy code (like assigning to an undeclared variable) instead throw errors. Another important detail: unlike function declarations, class declarations are not hoisted in a usable way. They exist in a "temporal dead zone" from the top of the scope until the class definition is evaluated, so referencing a class before its declaration throws a ReferenceError.

Instance fields (declared directly in the class body, outside any method) are assigned onto each new object as the constructor runs, in the order they appear, before the rest of the constructor body executes. Fields prefixed with # are private — they live in an internal slot that is only accessible from code written inside the class itself, not from outside, not even via bracket notation. static members attach to the class (constructor function) itself rather than to instances, which is why you call them as ClassName.staticMethod() rather than on an object.

Syntax

The general shape of a class is a class keyword, a name, an optional extends clause for inheritance, and a body containing a constructor, methods, fields, and static members.

Part Purpose
class Name { } Declares a class named Name.
constructor(args) Runs once when new Name(...) is called; sets up instance state.
extends Parent Makes Name inherit from Parent‘s prototype chain.
super(args) Calls the parent constructor; required before using this in a derived constructor.
method() { } An instance method, placed on Name.prototype.
get x() / set x(v) Accessor methods that behave like a property, obj.x.
static method() { } A method callable on the class itself, not on instances.
#field A private instance field, accessible only inside the class body.

Examples

Example 1: A Basic Class with a Getter

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  get area() {
    return this.width * this.height;
  }

  describe() {
    return `Rectangle ${this.width}x${this.height}, area = ${this.area}`;
  }
}

const rect = new Rectangle(4, 5);
console.log(rect.describe());
console.log(rect.area);

Output:

Rectangle 4x5, area = 20
20

The constructor stores width and height on the instance. The get area() accessor lets you read rect.area like a plain property, even though it’s computed on every access rather than stored. describe() lives on Rectangle.prototype and is shared by every rectangle you create.

Example 2: Inheritance with extends and super

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} makes a sound.`;
  }

  static create(name) {
    return new Animal(name);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    return `${super.speak()} ${this.name} barks!`;
  }
}

const dog = new Dog('Rex', 'Labrador');
console.log(dog.speak());
console.log(dog instanceof Animal);
console.log(Animal.create('Generic').speak());

Output:

Rex makes a sound. Rex barks!
true
Generic makes a sound.

Dog extends Animal links Dog.prototype to Animal.prototype, so a Dog instance can use anything defined on Animal. Inside Dog‘s constructor, super(name) must run before this is used, because it is what actually creates the base object state. The overridden speak() method calls super.speak() to reuse the parent’s implementation instead of duplicating its logic. dog instanceof Animal is true because of the prototype chain, and static create shows a static method used as a factory.

Example 3: Private Fields and Static Counters

class BankAccount {
  #balance;
  static accountsCreated = 0;

  constructor(owner, initialBalance = 0) {
    this.owner = owner;
    this.#balance = initialBalance;
    BankAccount.accountsCreated++;
  }

  deposit(amount) {
    if (amount <= 0) {
      throw new Error('Deposit amount must be positive');
    }
    this.#balance += amount;
    return this.#balance;
  }

  get balance() {
    return this.#balance;
  }
}

const acc1 = new BankAccount('Alice', 100);
acc1.deposit(50);
console.log(acc1.balance);
console.log(BankAccount.accountsCreated);

const acc2 = new BankAccount('Bob');
console.log(BankAccount.accountsCreated);

Output:

150
1
2

The #balance field cannot be read or modified from outside the class — acc1.#balance or acc1['#balance'] would be a syntax or runtime error from external code, giving you real encapsulation, unlike a plain _balance naming convention. static accountsCreated lives on the class itself and increments every time any instance is constructed, so it acts as a shared counter across all accounts.

Under the Hood: What class Really Compiles To

It helps to see the mechanics directly. Every class is, structurally, a constructor function plus a prototype object, exactly like the pre-ES6 pattern of function Shape() {} combined with Shape.prototype.area = function() {...}.

class Shape {
  area() {
    return 0;
  }
}

console.log(typeof Shape);
console.log(Shape.prototype.area === Shape.prototype.area);
console.log(Object.getPrototypeOf(new Shape()) === Shape.prototype);

Output:

function
true
true

Step by step, when you call new Shape(): (1) a brand-new plain object is created; (2) its internal [[Prototype]] is set to Shape.prototype, which is what the last console.log confirms; (3) the constructor function body runs with this bound to that new object; (4) unless the constructor explicitly returns another object, the new object is returned automatically. The key differences from an old-style constructor function are enforced by the engine, not just convention: you cannot call a class without new, class methods are non-enumerable on the prototype (so they don’t show up in for...in loops or Object.keys), and the whole class body runs in strict mode.

Common Mistakes

Mistake 1: Calling a Class Without new

A very common error for people coming from regular functions is trying to call a class like a normal function.

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

try {
  const p = Point(1, 2);
} catch (error) {
  console.log(error.message);
}

Output:

Class constructor Point cannot be invoked without 'new'

Class constructors are intentionally locked down: they throw a TypeError if invoked without new, unlike ordinary functions which silently run with this pointing somewhere unexpected. The fix is simply to always use new:

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

const p = new Point(1, 2);
console.log(`${p.x}, ${p.y}`);

Output:

1, 2

Mistake 2: Using this Before Calling super()

In a derived class, the base class is responsible for actually creating the object’s state, so JavaScript forbids touching this until super() has run.

class Base {
  constructor(name) {
    this.name = name;
  }
}

class Derived extends Base {
  constructor(name, extra) {
    this.extra = extra;
    super(name);
  }
}

try {
  const d = new Derived('test', 42);
} catch (error) {
  console.log(error.message);
}

Output:

Must call super constructor in this before accessing 'this' or returning from derived constructor

The fix is to always call super(...) as the very first statement in a derived constructor, before touching this in any way:

class Base {
  constructor(name) {
    this.name = name;
  }
}

class Derived extends Base {
  constructor(name, extra) {
    super(name);
    this.extra = extra;
  }
}

const d = new Derived('test', 42);
console.log(d.name, d.extra);

Output:

test 42

Best Practices

  • Use classes when you have both state and closely related behavior; for pure data or simple utility functions, a plain object or standalone function is often simpler.
  • Prefer private fields (#field) over an underscore naming convention when you need real encapsulation — the underscore is only a hint, not enforcement.
  • Always call super(...) first thing in a derived constructor, even if you have no other setup to do before it.
  • Favor composition over deep inheritance chains; two or three levels of extends is usually a sign to reconsider the design.
  • Use static factory methods (like Animal.create()) when object creation needs logic beyond what a constructor can cleanly express.
  • If a method needs to be passed as a callback (e.g. to addEventListener) and must keep its this, define it as an arrow-function class field rather than relying on the caller to bind it correctly.
  • Keep constructors focused on assigning fields; put real logic in named methods so it’s testable and reusable.

Practice Exercises

  • Create a Vehicle class with make and model fields and a static field totalCreated that increments every time a new Vehicle is constructed. Log the counter after creating three vehicles.
  • Create an ElectricCar class that extends Vehicle, adds a batteryRangeKm field, and overrides a describe() method so it calls the parent’s describe() via super.describe() and appends the battery range.
  • Add a private field #odometer to Vehicle (starting at 0) with a drive(km) method that adds to it and throws an Error if km is negative, plus a getter odometer to read the current value from outside the class.

Summary

  • A class is syntactic sugar over JavaScript’s prototype system: methods live on the shared prototype, fields live on each instance.
  • constructor runs on new, extends/super wire up inheritance, and static members belong to the class itself.
  • Private fields (#name) provide true encapsulation, unlike naming conventions.
  • Class declarations are not usable before their definition (temporal dead zone), and class constructors cannot be called without new.
  • In a derived class, super() must run before any use of this.
  • Prefer classes for state-plus-behavior entities, and favor composition over deep inheritance hierarchies.