JavaScript · Intermediate

Classes in JavaScript

11 min readUpdated September 24, 2026Every example verified

In short: A JavaScript class (ES2015) is a template for objects: its constructor sets up each new instance, its methods are shared through the prototype, static members belong to the class itself, and extends with super builds one class on another. Private fields, written with a leading # (ES2022), are accessible only inside the class body.

What a class gives you

Objects in JavaScript can be built by hand as literals, but when a program needs many objects with the same shape and the same behaviour, a class describes that shape once. new LibraryBook("Atlas", 2) creates an object, runs the class's constructor with this bound to that object, and returns it. Every instance gets its own copies of the properties the constructor assigns and shares one copy of each method.

The sharing works through the prototype. Methods written in the class body are placed on LibraryBook.prototype, and every instance links to that object. Calling atlas.lend() finds no lend on atlas itself, follows the link and finds it on the prototype, then runs it with this set to atlas. Class syntax is a clearer way to write what constructor functions and prototypes always did; there is no new object model underneath, which is why typeof LibraryBook is "function".

Inside a method, this is whichever object the method was called on. That binding happens at call time, not at definition time, so a method extracted from its object, const f = atlas.lend; f(), runs with this undefined in strict mode (class bodies are always strict) and fails. Wrap such calls in an arrow function or use .bind when you must pass a method as a callback.

A getter declared with get available() runs a computation when the property is read, so callers write book.available rather than book.available(). A setter with set pairs with it for validated assignment. Static members, declared with static, live on the class rather than on instances: a counter of how many objects were made, or a factory method such as Wallet.empty(owner) that returns a configured instance.

class StudentTicket extends Ticket makes Ticket the parent. The child inherits every method, may override any of them by redefining it, and can reach the parent's version with super.method(). A child constructor must call super(...) before touching this, because the parent constructor is what creates the object. instanceof walks the prototype chain, so a StudentTicket is also an instance of Ticket.

Private fields (ES2022) are declared in the class body with a # prefix, #balance, and can be read or written only inside that class body. They are not properties: Object.keys, JSON.stringify and bracket access cannot see them, and referring to obj.#balance outside the class is a syntax error rather than a runtime undefined. Public fields can also be declared in the body, count = 0;, as a shorthand for assigning them in the constructor.

Unlike function declarations, classes are not hoisted for use: they sit in the temporal dead zone until their declaration runs, so a class must be declared above the first new. Calling a class without new throws a TypeError.

Syntax

 JavaScript · syntax
class Account {
  static #created = 0;        // private static field
  #balance = 0;               // private instance field
  owner;                      // public instance field

  constructor(owner) {
    this.owner = owner;
    Account.#created += 1;
  }

  deposit(amount) { this.#balance += amount; return this; }   // method (on the prototype)
  get balance() { return this.#balance; }                     // getter
  static get created() { return Account.#created; }           // static getter
  static open(owner) { return new Account(owner); }           // static factory
}

class Savings extends Account {
  constructor(owner, rate) {
    super(owner);             // must run before this is used
    this.rate = rate;
  }
  deposit(amount) { return super.deposit(amount * (1 + this.rate)); }   // override
}

Class bodies are always strict mode. No commas between members. Methods declared with static are called on the class, Account.open("Ina"), not on instances.

Constructor, method and getter

A library book with a fixed number of copies. lend updates the state through this, available is a getter, and the final lines look at what an instance actually contains.

 JavaScript
class LibraryBook {
  constructor(title, copies) {
    this.title = title;
    this.copies = copies;
    this.onLoan = 0;
  }

  get available() {
    return this.copies - this.onLoan;
  }

  lend() {
    if (this.available === 0) {
      return `${this.title}: none left`;
    }
    this.onLoan += 1;
    return `${this.title}: lent, ${this.available} left`;
  }
}

const atlas = new LibraryBook("Coastal Atlas", 2);
console.log(atlas.lend());
console.log(atlas.lend());
console.log(atlas.lend());
console.log(atlas instanceof LibraryBook, typeof LibraryBook);
console.log(Object.keys(atlas));
console.log(atlas.lend === LibraryBook.prototype.lend);

Output

Coastal Atlas: lent, 1 left
Coastal Atlas: lent, 0 left
Coastal Atlas: none left
true function
[ 'title', 'copies', 'onLoan' ]
true

The constructor stores three properties on the new object, and those are the only own keys the instance has: Object.keys lists them and nothing else. available is read like a property but computed each time from the other two. lend is not copied into the instance; the last line shows that atlas.lend is the very function stored on LibraryBook.prototype, shared by every book.

Inheritance with extends and super

StudentTicket inherits from Ticket, overrides price and describe, and calls the parent versions with super. The final lines check the relationships.

 JavaScript
class Ticket {
  constructor(holder, basePrice) {
    this.holder = holder;
    this.basePrice = basePrice;
  }
  price() {
    return this.basePrice;
  }
  describe() {
    return `${this.holder} pays ${this.price()}`;
  }
}

class StudentTicket extends Ticket {
  constructor(holder, basePrice, college) {
    super(holder, basePrice);
    this.college = college;
  }
  price() {
    return super.price() / 2;
  }
  describe() {
    return super.describe() + ` (student, ${this.college})`;
  }
}

const tickets = [new Ticket("Noor", 30), new StudentTicket("Leo", 30, "Riverside")];
for (const t of tickets) {
  console.log(t.describe());
}
console.log(tickets[1] instanceof Ticket, tickets[0] instanceof StudentTicket);
console.log(Object.getPrototypeOf(StudentTicket.prototype) === Ticket.prototype);

Output

Noor pays 30
Leo pays 15 (student, Riverside)
true false
true

Ticket.describe calls this.price(), and for Leo this is a StudentTicket, so the overriding price runs and halves the amount even though describe itself was inherited unchanged. super.describe() reuses the parent's text and appends to it. A student ticket is an instance of both classes, a plain ticket only of Ticket, and the last line shows the mechanism: the child's prototype links to the parent's.

Private fields and static members

A wallet keeps its balance in a private field and counts instances in a private static field. Notice what Object.keys and JSON.stringify can and cannot see.

 JavaScript
class Wallet {
  static #count = 0;
  #balance;

  constructor(owner, opening) {
    this.owner = owner;
    this.#balance = opening;
    Wallet.#count += 1;
  }

  static get created() {
    return Wallet.#count;
  }

  static empty(owner) {
    return new Wallet(owner, 0);
  }

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

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

const w = new Wallet("Tomas", 40);
w.deposit(10).deposit(5);
console.log(w.balance);
console.log(Object.keys(w));
console.log(JSON.stringify(w));
const spare = Wallet.empty("Ada");
console.log(spare.balance, Wallet.created);
try {
  spare.deposit(-3);
} catch (err) {
  console.log(err.name + ": " + err.message);
}

Output

55
[ 'owner' ]
{"owner":"Tomas"}
0 2
RangeError: deposit must be positive

#balance is real state, yet Object.keys shows only owner and the JSON output omits it: private fields are not properties. The only way in is through the class's own methods, which is what lets deposit refuse a negative amount. deposit returns this so calls chain. Wallet.created and Wallet.empty are called on the class, and the count is 2 because the static factory also ran the constructor.

Kinds of class members

MemberDeclared asLives onAccessed as
Instance propertythis.x = ... in the constructor, or x = ... in the bodyeach instanceobj.x
Methodname() { }the prototype, sharedobj.name()
Getter / setterget name() { } / set name(v) { }the prototypeobj.name (no parentheses)
Static property or methodstatic name = ... / static name() { }the class itselfClassName.name
Private field or method#nameeach instance (or the class if static)this.#name inside the class only

Common mistakes

  • Forgetting new

    Why it goes wrong: const w = Wallet("Ada", 5) throws a TypeError: a class constructor cannot be invoked without new. Nothing is created and no this exists.

    Fix: Always write new Wallet(...), or provide a static factory such as Wallet.empty(owner) if you prefer calls without new.

  • Using this before super() in a subclass constructor

    Why it goes wrong: The parent constructor is what creates and initialises the object. Until super(...) returns there is no this, and touching it throws a ReferenceError.

    Fix: Call super(...) first, then assign the child's own properties.

     JavaScript · fix
    constructor(holder, basePrice, college) {
      super(holder, basePrice);
      this.college = college;
    }
  • Passing a method as a callback and losing this

    Why it goes wrong: setTimeout(timer.tick, 1000) passes the bare function. When it runs, this is undefined (class bodies are strict), so this.count throws.

    Fix: Wrap it, () => timer.tick(), bind it once in the constructor, this.tick = this.tick.bind(this), or declare it as an arrow-function field, tick = () => { ... }.

  • Expecting a class to be usable before its declaration

    Why it goes wrong: Unlike function declarations, classes are in the temporal dead zone until their line runs, so new Shape() above class Shape {} throws a ReferenceError.

    Fix: Declare classes before the code that instantiates them, typically near the top of the file or in their own module.

Where you use this

Classes suit things that have identity and change over time: a shopping cart, a game entity, a connection to a service, a UI component. The constructor establishes valid starting state, methods are the only operations, and private fields stop other code from putting the object into an impossible state, such as a negative balance. When several kinds share behaviour, a small hierarchy with extends lets the shared code live once in the parent while each child overrides the part that differs.

Many JavaScript codebases use classes sparingly and prefer plain objects and functions for data that does not change or has no invariants; the closures lesson shows how private state can also be kept without a class. Choose a class when there is behaviour to attach and state to protect, and when instanceof or a shared prototype genuinely helps. Custom error types, covered in error handling, are one case where extends Error is the standard approach.

 JavaScript · in practice
class Cart {
  #lines = [];
  add(sku, qty) { this.#lines.push({ sku, qty }); return this; }
  get count() { return this.#lines.reduce((n, l) => n + l.qty, 0); }
}

Key points

  • new creates the object, runs constructor with this bound to it and returns it.
  • Methods live on the prototype and are shared; properties assigned in the constructor are per instance.
  • this is bound at call time: a method passed as a bare callback loses it.
  • Getters compute a property on read; static members belong to the class, not to instances.
  • extends inherits, super(...) must run before this in a child constructor, and super.method() calls the parent version.
  • Private #fields (ES2022) are invisible to Object.keys and JSON and only reachable inside the class body.
  • Classes are not hoisted for use and cannot be called without new.

Try it yourself

Add a perimeter method that returns 2 * (width + height) and a static square(side) factory that returns a Rectangle with equal sides, then uncomment the two calls. The program should print 12, 14 and 25.

Your program
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }
  area() {
    return this.width * this.height;
  }
}

const r = new Rectangle(4, 3);
console.log(r.area());
// console.log(r.perimeter());
// console.log(Rectangle.square(5).area());
Expected output: 12 14 25

Practise this

Exercises for this lesson are in the JavaScript practice set.

Open the JavaScript playground

Frequently asked questions

What is the difference between a class and a constructor function in JavaScript?

They produce the same kind of object with the same prototype chain; class is syntax added in ES2015 for what constructor functions and manual prototype assignments did before. The differences are in the rules: a class body is always strict mode, a class cannot be called without new, it is not hoisted for use before its declaration, and only classes can declare private # fields.

Are private fields really private?

Yes, in the language sense. Code outside the class body cannot read or write #field; the reference is a syntax error, and the field does not appear through Object.keys, for...in, JSON.stringify or bracket access. Debuggers can still display them, and a method of the class can expose them on purpose, but no outside code can reach them by accident.

When should I use a static method?

When the operation belongs to the class as a whole rather than to one instance: alternative constructors such as Point.fromString(text), helpers that compare two instances, or counters shared by all instances. If the code needs a particular object's data through this, it should be an ordinary method.

Progress is stored only in this browser.

How this page was checked. Every program on it was run with Node.js 22 at build time; runs in your browser in an isolated Web Worker by the publishing checks, and the output shown is what it printed. The Run buttons execute the same code on your device; nothing you type is sent anywhere.