From Loose Objects to a Blueprint
Think about a bank. Every customer has an account with a holder name and a balance. Every account can take a deposit and give a withdrawal. The rules are the same for millions of accounts: you cannot withdraw more than you have, and nobody outside the bank can simply type a new balance.
In the JavaScript Objects lesson you made single objects with curly braces. That works for one account, but a bank needs a blueprint that creates as many accounts as it likes, each with its own data but the same behaviour. In JavaScript that blueprint is a class.
In this lesson you will build a BankAccount class step by step, protect its balance with private fields, and then create a SavingsAccount that earns interest by extending it. This style of programming, where data and the functions that work on it live together, is called object-oriented programming (OOP).
Your First Class
A class has a name (by convention starting with a capital letter), a constructor and some methods:
- The constructor runs once, every time you create a new object. It sets up the starting data with
this. - Methods are functions that every object made from the class can use.
- The keyword
newcreates an object, called an instance, from the class.
class BankAccount {
constructor(holder, openingBalance) {
this.holder = holder;
this.balance = openingBalance;
}
deposit(amount) {
this.balance += amount;
console.log(`${this.holder} deposited Rs ${amount}. Balance: Rs ${this.balance}`);
}
withdraw(amount) {
if (amount > this.balance) {
console.log(`Sorry ${this.holder}, not enough balance`);
return;
}
this.balance -= amount;
console.log(`${this.holder} withdrew Rs ${amount}. Balance: Rs ${this.balance}`);
}
}
const priya = new BankAccount("Priya", 5000);
const arjun = new BankAccount("Arjun", 1200);
priya.deposit(2000); // Priya deposited Rs 2000. Balance: Rs 7000
arjun.withdraw(1500); // Sorry Arjun, not enough balance
arjun.withdraw(200); // Arjun withdrew Rs 200. Balance: Rs 1000
console.log(priya.balance, arjun.balance); // 7000 1000
console.log(priya.deposit === arjun.deposit); // true (one shared method)
Inside a method, this means “the account this method was called on”. When you write priya.deposit(2000), this is priya. You will study this in depth in the JavaScript this Keyword lesson.
The last line shows something useful. Each account has its own holder and balance, but they all share one copy of each method. Behind the scenes, methods live on the class’s prototype, an object that every instance can look things up in. A class is a cleaner way to write this prototype system, which JavaScript has always had.
No commas between methods. Unlike an object literal, the methods inside a class body are not separated by commas. Adding one is a syntax error.
Getters and Setters
A getter is a method that you read like a property, without brackets. A setter is a method that runs when you assign a value to a property. They let you compute values on the fly and check data before saving it.
class BankAccount {
constructor(holder, balance) {
this.holder = holder;
this.balance = balance;
this._nominee = "Not added";
}
// A getter: read it like a property, no brackets
get summary() {
return `${this.holder} has Rs ${this.balance}. Nominee: ${this._nominee}`;
}
// A setter: runs when you assign a value
set nominee(name) {
if (name.trim().length < 2) {
console.log("Nominee name is too short");
return;
}
this._nominee = name.trim();
}
get nominee() {
return this._nominee;
}
}
const account = new BankAccount("Sunita", 8000);
console.log(account.summary); // Sunita has Rs 8000. Nominee: Not added
account.nominee = "R"; // Nominee name is too short
account.nominee = " Rohit ";
console.log(account.nominee); // Rohit
console.log(account.summary); // Sunita has Rs 8000. Nominee: Rohit
The setter refuses a one-letter nominee name and trims extra spaces. The real value is stored in _nominee; the underscore is an old convention that means “please do not touch this from outside”. It is only a polite request. Next you will see how to make data truly private.
Static Properties and Methods
Some data belongs to the whole bank, not to one account: the bank’s name, or how many accounts it has opened. Mark these with static. You use them through the class name, like BankAccount.totalAccounts, not through an instance. Static methods are good for helper functions related to the class, such as formatting rupees.
class BankAccount {
static bankName = "Janata Cooperative Bank";
static totalAccounts = 0;
constructor(holder, balance) {
this.holder = holder;
this.balance = balance;
BankAccount.totalAccounts++;
this.number = "JCB" + (1000 + BankAccount.totalAccounts);
}
static formatRupees(amount) {
return amount.toLocaleString("en-IN", { style: "currency", currency: "INR" });
}
}
const a = new BankAccount("Imran", 125000);
const b = new BankAccount("Divya", 4300.5);
console.log(BankAccount.bankName); // Janata Cooperative Bank
console.log(BankAccount.totalAccounts); // 2
console.log(a.number, b.number); // JCB1001 JCB1002
console.log(BankAccount.formatRupees(a.balance)); // ₹1,25,000.00
console.log(a.formatRupees); // undefined (static belongs to the class)
Notice toLocaleString("en-IN", ...) groups the digits the Indian way, as 1,25,000, with the rupee symbol in front.
Private Fields with #
In our first class, anyone could write priya.balance = 99999999. A real bank would never allow that. A field whose name starts with #, like #balance, is private: only code written inside the class can read or change it. Outside code gets a syntax error if it even tries to mention it.
The usual pattern is a private field plus a public getter, so the outside world can see the balance but can only change it through methods like deposit that check the rules. This idea is called encapsulation: hide the data, expose safe actions.
class BankAccount {
#balance = 0; // private: only code inside this class can touch it
constructor(holder, openingBalance) {
this.holder = holder;
this.#balance = openingBalance;
}
get balance() {
return this.#balance; // read-only from outside: there is no setter
}
deposit(amount) {
if (amount <= 0) {
throw new Error("Deposit must be more than zero");
}
this.#balance += amount;
}
}
const account = new BankAccount("Kavya", 5000);
console.log(account.balance); // 5000 (read through the getter)
account.deposit(1500);
console.log(account.balance); // 6500
try {
account.deposit(-500);
} catch (error) {
console.log(error.message); // Deposit must be more than zero
}
// console.log(account.#balance); // a SyntaxError if you remove the // at the start
Because there is no setter, a line like account.balance = 1000000 cannot change the money. It is silently ignored in normal scripts and throws a TypeError in strict mode. Methods can be private too, such as #record() in the bigger example below. Use private members for anything that is an internal detail, so you can change it later without breaking code that uses your class.
Inheritance with extends and super
A savings account is a bank account with one extra feature: it earns interest. Instead of copying all the code, you write class SavingsAccount extends BankAccount. The new class, called the child or subclass, gets every method of the parent and can add or replace methods.
- In the child’s constructor, call
super(...)to run the parent’s constructor. You must do this before usingthis. - Inside a method,
super.methodName()calls the parent’s version, so you can extend it instead of rewriting it. instanceofchecks whether an object was made from a class or any of its children.
class BankAccount {
#balance;
constructor(holder, openingBalance) {
this.holder = holder;
this.#balance = openingBalance;
}
get balance() {
return this.#balance;
}
deposit(amount) {
this.#balance += amount;
}
describe() {
return `${this.holder}: Rs ${this.#balance}`;
}
}
class SavingsAccount extends BankAccount {
constructor(holder, openingBalance, interestRate) {
super(holder, openingBalance); // run the parent constructor first
this.interestRate = interestRate;
}
addInterest() {
const interest = Math.round((this.balance * this.interestRate) / 100);
this.deposit(interest); // a method inherited from BankAccount
return interest;
}
describe() {
return super.describe() + ` (savings at ${this.interestRate}% a year)`;
}
}
const ramesh = new SavingsAccount("Ramesh", 20000, 3.5);
console.log(ramesh.addInterest()); // 700
console.log(ramesh.describe()); // Ramesh: Rs 20700 (savings at 3.5% a year)
console.log(ramesh instanceof SavingsAccount); // true
console.log(ramesh instanceof BankAccount); // true
console.log(new BankAccount("Asha", 100) instanceof SavingsAccount); // false
One detail: the child cannot read the parent’s #balance directly. Private really means private, even to children. The child uses the public balance getter and the deposit method instead, which is exactly what keeps the rules in one place.
Real-Life Example: A Savings Account Page
Here is a small banking page. The BankAccount class keeps a private balance and a private statement history. The SavingsAccount child adds interest and replaces canWithdraw to enforce a minimum balance of Rs 1,000. Notice how the page code never touches the balance directly; it only calls methods and shows errors.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My Savings Account</title>
<style>
body { font-family: sans-serif; padding: 16px; max-width: 420px; }
.balance { font-size: 28px; font-weight: bold; }
input, button { padding: 8px; font-size: 16px; margin: 4px 0; }
li { margin: 4px 0; }
.error { color: crimson; }
</style>
</head>
<body>
<h2 id="title"></h2>
<p class="balance" id="balance"></p>
<input id="amount" type="number" min="1" placeholder="Amount in Rs">
<div>
<button id="deposit">Deposit</button>
<button id="withdraw">Withdraw</button>
<button id="interest">Add yearly interest</button>
</div>
<p id="message"></p>
<h3>Statement</h3>
<ul id="statement"></ul>
<script>
class BankAccount {
#balance;
#history = [];
constructor(holder, openingBalance) {
this.holder = holder;
this.#balance = openingBalance;
this.#record("Opening balance", openingBalance);
}
get balance() {
return this.#balance;
}
get history() {
return [...this.#history]; // a copy, so outside code cannot change it
}
canWithdraw(amount) {
return amount <= this.#balance;
}
deposit(amount, note = "Deposit") {
if (!(amount > 0)) throw new Error("Enter an amount more than zero");
this.#balance += amount;
this.#record(note, amount);
}
withdraw(amount) {
if (!(amount > 0)) throw new Error("Enter an amount more than zero");
if (!this.canWithdraw(amount)) throw new Error("This withdrawal is not allowed");
this.#balance -= amount;
this.#record("Withdrawal", -amount);
}
#record(note, amount) {
this.#history.push({ note, amount, balance: this.#balance });
}
static format(amount) {
return amount.toLocaleString("en-IN", { style: "currency", currency: "INR" });
}
}
class SavingsAccount extends BankAccount {
static MIN_BALANCE = 1000;
constructor(holder, openingBalance, interestRate) {
super(holder, openingBalance);
this.interestRate = interestRate;
}
canWithdraw(amount) {
return this.balance - amount >= SavingsAccount.MIN_BALANCE;
}
addInterest() {
const interest = Math.round((this.balance * this.interestRate) / 100);
this.deposit(interest, "Interest at " + this.interestRate + "%");
}
}
const account = new SavingsAccount("Neha Gupta", 15000, 4);
const message = document.querySelector("#message");
const amountInput = document.querySelector("#amount");
function render() {
document.querySelector("#title").textContent = account.holder + ", Savings Account";
document.querySelector("#balance").textContent = BankAccount.format(account.balance);
const list = document.querySelector("#statement");
list.innerHTML = "";
account.history.forEach((entry) => {
const li = document.createElement("li");
li.textContent = entry.note + ": " + BankAccount.format(entry.amount) +
" (balance " + BankAccount.format(entry.balance) + ")";
list.append(li);
});
}
function run(action) {
try {
action(Number(amountInput.value));
message.className = "";
message.textContent = "Done.";
} catch (error) {
message.className = "error";
message.textContent = error.message;
}
render();
}
document.querySelector("#deposit").addEventListener("click", () => run((n) => account.deposit(n)));
document.querySelector("#withdraw").addEventListener("click", () => run((n) => account.withdraw(n)));
document.querySelector("#interest").addEventListener("click", () => run(() => account.addInterest()));
render();
</script>
</body>
</html>
Try these changes:
- Change
MIN_BALANCEto 5000 and try to withdraw Rs 12,000. - Add a getter
totalDepositstoBankAccountthat adds up all positive amounts in the history, and show it on the page. - Add a static method
BankAccount.compare(a, b)that returns the holder with the higher balance.
When Classes Help, and When Plain Objects Are Enough
| Use a class when | Use a plain object when |
|---|---|
| You need many objects with the same behaviour, like 1,000 accounts | You have one simple thing, like a settings object |
| Data must be protected by rules (private fields and methods) | It is just data, such as JSON from an API |
| Types are related and share code (savings and current accounts) | Small helper functions are enough |
Do not force everything into classes. Much modern JavaScript uses plain objects and functions, and that is fine. Reach for a class when objects have both state and rules that belong together.
Common Mistakes
- Forgetting
new. Calling a class like a function throws an error:
class BankAccount {
constructor(holder) {
this.holder = holder;
}
}
const good = new BankAccount("Ravi");
console.log(good.holder); // Ravi
const bad = BankAccount("Ravi"); // ERROR: TypeError: Class constructor BankAccount cannot be invoked without 'new'
- Using
thisbeforesuper()in a child constructor. This throws aReferenceError. Callsuper(...)as the first line. - Putting commas between methods, as you would in an object literal. Class bodies have no commas.
- Calling a static method on an instance, like
account.formatRupees(). Static methods belong to the class:BankAccount.formatRupees(). - Passing a method as a callback, like
button.addEventListener("click", account.deposit). The method loses itsthis. Use an arrow function,() => account.deposit(100), as the bank page does.
Practice Task: A Current Account with Overdraft
Shops often use a current account that may go below zero, up to an agreed overdraft limit. Starting from the BankAccount class below, create a CurrentAccount child class that:
- Takes an extra
overdraftLimitin its constructor and callssuper. - Overrides
canWithdrawso the balance may go down to minus the limit. - Has a getter
isOverdrawnthat istruewhen the balance is below zero. - Passes the tests written in the comments.
class BankAccount {
#balance;
constructor(holder, openingBalance) {
this.holder = holder;
this.#balance = openingBalance;
}
get balance() {
return this.#balance;
}
canWithdraw(amount) {
return amount <= this.#balance;
}
withdraw(amount) {
if (!this.canWithdraw(amount)) {
throw new Error(`${this.holder}: withdrawal of Rs ${amount} refused`);
}
this.#balance -= amount;
}
}
// TODO 1: create class CurrentAccount that extends BankAccount.
// The constructor takes (holder, openingBalance, overdraftLimit).
// TODO 2: override canWithdraw so the balance may go down to -overdraftLimit.
// TODO 3: add a getter isOverdrawn that is true when the balance is below 0.
// TODO 4: test it:
// const shop = new CurrentAccount("Sharma Kirana Store", 3000, 5000);
// shop.withdraw(7000); -> balance -4000, isOverdrawn true
// shop.withdraw(2000); -> refused (would go to -6000)
// console.log(shop instanceof BankAccount); -> true
Show one possible answer (try it yourself first!)
class BankAccount {
#balance;
constructor(holder, openingBalance) {
this.holder = holder;
this.#balance = openingBalance;
}
get balance() {
return this.#balance;
}
canWithdraw(amount) {
return amount <= this.#balance;
}
withdraw(amount) {
if (!this.canWithdraw(amount)) {
throw new Error(`${this.holder}: withdrawal of Rs ${amount} refused`);
}
this.#balance -= amount;
}
}
class CurrentAccount extends BankAccount {
constructor(holder, openingBalance, overdraftLimit) {
super(holder, openingBalance);
this.overdraftLimit = overdraftLimit;
}
canWithdraw(amount) {
return this.balance - amount >= -this.overdraftLimit;
}
get isOverdrawn() {
return this.balance < 0;
}
}
const shop = new CurrentAccount("Sharma Kirana Store", 3000, 5000);
shop.withdraw(7000);
console.log(shop.balance, shop.isOverdrawn); // -4000 true
try {
shop.withdraw(2000);
} catch (error) {
console.log(error.message); // Sharma Kirana Store: withdrawal of Rs 2000 refused
}
console.log(shop instanceof BankAccount); // true
Key Takeaways
- A class is a blueprint.
new ClassName(...)runs theconstructorand returns a new instance. - Methods are shared by all instances. Getters and setters look like properties but run code.
staticmembers belong to the class itself, not to each object.- Fields and methods starting with
#are truly private. Expose safe methods and getters instead. extendscreates a child class;super()calls the parent constructor andsuper.method()the parent method.instanceofchecks whether an object comes from a class or its children.