Classes, constructors and methods
Module 4 built orders as plain objects. That works until you have fifty of them and every one needs a total, a description and a validity check — at which point you are copying the same three functions into every object, or passing objects into loose functions and hoping they have the right shape.
A class is a template: define the shape and the behaviour once, stamp out as many as you need.
Defining one
class Order {
constructor(customer, plates) {
this.customer = customer;
this.plates = plates;
}
describe() {
return `${this.customer}: ${this.plates} plates`;
}
}
const order = new Order('Priya', 3);
console.log(order.describe());
Priya: 3 plates
new does four things, and knowing them stops classes being magic:
- Creates a fresh empty object.
- Sets
thisto that object. - Runs the
constructorbody. - Returns the object — you do not write
return.
constructor is a reserved name, runs once per object, and is where the
per-instance data is set. Methods go beside it, with no function keyword and
no commas between them — a class body is not an object literal, and treating it
like one is the commonest syntax mistake.
Class names are PascalCase by convention, which is why module 2 said to keep
that spelling for classes and camelCase for everything else.
new is not optional
const order = Order('Priya', 3);
TypeError: Class constructor Order cannot be invoked without 'new'
A helpfully specific error. Older constructor functions failed silently here, which is one of several reasons classes replaced them.
Classes are also not hoisted usefully:
new Later();
class Later {}
ReferenceError: Cannot access 'Later' before initialization
The same temporal dead zone as let and const, from module 3. Define before
you use.
Getters: values that are computed
A total should never be stored — it would go stale the moment the plate count changed. A getter computes it on every read while looking like a property:
class Order {
constructor(customer, plates) {
this.customer = customer;
this.plates = plates;
}
get total() {
return this.plates * 80;
}
}
const order = new Order('Priya', 3);
console.log(order.total);
order.plates = 5;
console.log(order.total);
240
400
No brackets. order.total reads as data and is calculated fresh each time.
A setter runs code on assignment, which is where validation and cleaning live:
class Order {
set note(text) {
this._note = text.trim();
}
get note() {
return this._note;
}
}
Use a getter whenever a value is derived from other values. Storing total
alongside plates means two sources of truth, and they will disagree.
Private fields
A field starting with # is genuinely inaccessible from outside:
class Order {
#note = '';
set note(text) {
this.#note = text.trim();
}
get note() {
return this.#note;
}
}
const order = new Order();
order.note = ' no chilli ';
console.log(order.note);
console.log(order.#note);
no chilli
SyntaxError: Private field '#note' must be declared in an enclosing class
Not a convention — a rule the language enforces, and at parse time, so it is a
SyntaxError rather than a runtime one. Private fields must be declared in the
class body before use.
This is the same privacy closures gave you in module 3. Closures do it with
scope, classes with #. Closures are lighter for one object; classes are better
when you need many of the same thing.
Static members
static belongs to the class, not to any instance:
class Order {
static rate = 80;
static count = 0;
constructor(customer, plates) {
this.customer = customer;
this.plates = plates;
Order.count += 1;
}
get total() {
return this.plates * Order.rate;
}
static compare(a, b) {
return a.plates - b.plates;
}
}
new Order('Priya', 3);
new Order('Arjun', 1);
console.log(Order.count);
2
Shared configuration and helpers that do not belong to one order. Order.compare
is exactly the comparison function sort wanted in module 4, kept where it makes
sense.
The reference
| Piece | Syntax | Notes |
|---|---|---|
| Constructor | constructor(a) { } |
Runs on new. One per class. |
| Method | describe() { } |
No function, no commas. |
| Getter | get total() { } |
Read without brackets. |
| Setter | set note(v) { } |
Runs on assignment. |
| Public field | plates = 0 |
Declared outside the constructor. |
| Private field | #note = '' |
Must be declared. SyntaxError outside. |
| Static | static rate = 80 |
On the class, not the instance. |
The trap: methods are not arrow functions, and this can be lost
Everything from module 3 applies, and classes are where it bites hardest:
const order = new Order('Priya', 3);
const describe = order.describe;
describe();
TypeError: Cannot read properties of undefined (reading 'customer')
Detached from the object, so this is undefined — and class bodies are always
strict mode, so it throws rather than quietly using the global object.
You will hit this passing a method to an event listener:
button.addEventListener('click', order.describe);
Three fixes, in order of preference:
button.addEventListener('click', () => order.describe());
button.addEventListener('click', order.describe.bind(order));
class Order {
describe = () => {
return `${this.customer}: ${this.plates} plates`;
};
}
The third is a class field holding an arrow function, which takes this from
the instance and cannot be detached. It costs one copy of the function per
object rather than one shared on the prototype — which is the next lesson.
What JSON does to an instance
Worth knowing before it surprises you:
console.log(JSON.stringify(new Order('Priya', 3)));
{"customer":"Priya","plates":3}
Own data only. Methods, getters and private fields are all gone — a getter is
not a property, so total does not appear. Parse that back and you have a plain
object that looks like an order and has none of its behaviour. Rebuild with
new Order(...) if you need the methods.
When not to use a class
Honest guidance, because classes are overused by people arriving from Java.
A class earns its place when you have many things of the same shape with behaviour attached, or when you need to model a hierarchy. An order, a validator, a custom error — all reasonable.
A plain object is better for data, and most of what you handle is data. An API response is a plain object. A configuration is a plain object. A single thing that exists once does not need a template.
A function is better than a class with one method. A class named
PriceCalculator whose only method is calculate is a function wearing a
costume.
Modern React and most front-end JavaScript use very few classes. You need them to read code, to write custom errors — the third lesson of this module — and occasionally to model something genuinely object-shaped.
Check your work
new Order('Priya', 3) then .describe() gives Priya: 3 plates. new
creates the object, sets this, runs the constructor and returns it.
Calling a class without new throws TypeError: Class constructor Order cannot be invoked without 'new'.
Using a class above its definition throws ReferenceError: Cannot access 'Later' before initialization — the same dead zone as let.
order.total is read without brackets and recalculates. Change plates from
3 to 5 and it goes 240 to 400. That is the reason to compute rather than store.
order.#note from outside is a SyntaxError, not a runtime error — private
fields are enforced at parse time and must be declared in the class body.
Order.count after two new Order(...) calls is 2. A static field lives on
the class and is shared.
A detached method loses this and throws, because class bodies are strict.
Fix with an arrow wrapper, bind, or a class field holding an arrow.
JSON.stringify(instance) gives {"customer":"Priya","plates":3} — own data
only, no methods, no getters, no private fields.
The Subscription class keeps its balance and history private and hands back
a copy:
class Subscription {
#balance;
#rate;
#history = [];
constructor(balance, rate) {
this.#balance = balance;
this.#rate = rate;
}
get balance() {
return this.#balance;
}
get history() {
return [...this.#history];
}
deliver(plates) {
const cost = plates * this.#rate;
if (cost > this.#balance) return 'Insufficient balance.';
this.#balance -= cost;
this.#history.push({ plates, cost });
return this.#balance;
}
}
return [...this.#history] matters: returning the array itself would let a
caller push to your private list, exactly as in module 3.
Class or closure? The closure version from module 3 does the same job in
fewer concepts and is the better choice for one subscription. The class wins once
you need many of them, or a Subscription that extends something — and it is
what most codebases will hand you, so you need to read both.
Practice
- Write an
Orderclass with a constructor, adescribe()method, and create two instances. - Call the class without
newand read the error. - Use a class before its definition and read the error. Compare it with the
leterror from module 3. - Add a
totalgetter. Changeplatesafterwards and confirm the total follows. - Store the total instead, as
this.total = plates * 80in the constructor. Changeplatesand watch the total go stale. Then go back to the getter. - Add a
#noteprivate field with a getter and setter that trims. Try to read#notefrom outside and read the error. - Add
static rateandstatic count, and confirm the count rises with each instance. - Lose
this. Assign a method to a variable, call it, read the error. Fix it all three ways and decide which you would use. JSON.stringifyan instance and account for everything missing from the output.- Harder. Write a
Subscriptionclass holding a balance, a rate and a delivery history.deliver(plates)should refuse when the balance is too low and otherwise record the delivery;historyshould return the deliveries without letting a caller modify the stored list. Then write the same thing as a closure-based factory from module 3, and say which you would rather maintain and why.
Next: prototypes — what a class actually is underneath, and how extends works.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship