JavaScript · Intermediate
Classes in JavaScript
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
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.
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.
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.
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
| Member | Declared as | Lives on | Accessed as |
|---|---|---|---|
| Instance property | this.x = ... in the constructor, or x = ... in the body | each instance | obj.x |
| Method | name() { } | the prototype, shared | obj.name() |
| Getter / setter | get name() { } / set name(v) { } | the prototype | obj.name (no parentheses) |
| Static property or method | static name = ... / static name() { } | the class itself | ClassName.name |
| Private field or method | #name | each 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 withoutnew. Nothing is created and nothisexists.Fix: Always write
new Wallet(...), or provide a static factory such asWallet.empty(owner)if you prefer calls withoutnew.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 nothis, and touching it throws a ReferenceError.Fix: Call
super(...)first, then assign the child's own properties.JavaScript · fixconstructor(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,thisisundefined(class bodies are strict), sothis.countthrows.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()aboveclass 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.
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
newcreates the object, runsconstructorwiththisbound to it and returns it.- Methods live on the prototype and are shared; properties assigned in the constructor are per instance.
thisis bound at call time: a method passed as a bare callback loses it.- Getters compute a property on read;
staticmembers belong to the class, not to instances. extendsinherits,super(...)must run beforethisin a child constructor, andsuper.method()calls the parent version.- Private
#fields(ES2022) are invisible toObject.keysand 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.
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());12
14
25class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
perimeter() {
return 2 * (this.width + this.height);
}
static square(side) {
return new Rectangle(side, side);
}
}
const r = new Rectangle(4, 3);
console.log(r.area());
console.log(r.perimeter());
console.log(Rectangle.square(5).area());Practise this
Exercises for this lesson are in the JavaScript practice set.
Open the JavaScript playground
Related lessons
- JavaScript Objects: Properties, Methods and NestingHow JavaScript objects group data as key-value properties: dot and bracket access, adding and deleting, methods with this, nesting and reference behaviour.10 min
- JavaScript Functions: Declarations, Arrows and Return ValuesHow to define and call JavaScript functions with declarations, expressions and arrow functions; parameters, default values, return and local scope.10 min
- Closures in JavaScriptWhat a JavaScript closure is, how a function keeps access to the scope it was created in, and how to use that for counters, factories and callbacks.10 min
- Error Handling in JavaScriptHow JavaScript reports and recovers from failure: throw and Error objects, try/catch/finally, built-in error types, custom error classes and the cause option.10 min
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.
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.