A JavaScript constructor and instance state are two separate ideas that beginners tend to blur into one. The constructor is the function that runs the moment you call a type with new. Instance state is everything that makes one object different from the next object of the same type. Understanding which is which tells you where each piece of data belongs, and most of the confusing bugs in object-oriented JavaScript come from putting a piece of data in the wrong place.
The page works through a small example — a bank account with an owner and a balance — and keeps checking the same three questions: is this field mine alone, is this behaviour shared, and can outside code reach it?
What actually happens when you call a type with new?
Four steps, in this order. A fresh, empty object is created. That object becomes this inside the constructor. The constructor runs and you normally assign fields onto this. Finally, the object is linked to the type's prototype and returned to you.
class BankAccount {
constructor(owner, openingBalance = 0) {
this.owner = owner;
this.balance = openingBalance;
}
deposit(amount) {
this.balance += amount;
return this.balance;
}
}
const account = new BankAccount("Asha", 500);
console.log(Object.keys(account));
console.log(account.deposit(250));
console.log(account.balance);
console.log(account instanceof BankAccount);
Every assignment to this in the constructor creates an own property on that one object. Object.keys(account) returns owner and balance and nothing else, because those are the only properties the object owns. After the deposit the balance is 750, and instanceof confirms the object came from BankAccount.
One wrinkle worth knowing early: if a constructor returns an object, that object is what new hands back, and the object you built is thrown away. Returning a primitive is ignored, and the fresh object is returned as usual. It is a legal trick for alternative constructors, but it is also an easy way to end up with an object that is not what you thought.
Which parts belong to the instance and which belong to the type?
Data belongs on the instance. Behaviour belongs on the prototype, where every object of that type can find it. Two accounts hold different balances but run the same describe method, and the method is not copied onto either account.
class BankAccount {
constructor(owner, balance) {
this.owner = owner;
this.balance = balance;
}
describe() {
return this.owner + " has " + this.balance;
}
}
const first = new BankAccount("Asha", 500);
const second = new BankAccount("Bilal", 120);
console.log(first.describe());
console.log(second.describe());
console.log(first.balance === second.balance);
console.log(first.describe === second.describe);
console.log(first.hasOwnProperty("describe"));
Read the last three lines as the summary. The two balances are not the same value, because each account owns its own. The two describe functions are the same function object, because the method lives on the shared prototype. And neither account owns describe — hasOwnProperty answers false, which is why it does not show up when you enumerate an account's own properties.
Do fields have to be assigned in the constructor?
No. A class body can declare fields directly, and each instance gets its own copy of them. This is useful for values that are the same for every instance but should still be per-object rather than shared.
class BankAccount {
owner = "unknown";
balance = 0;
currency = "INR";
constructor(owner, openingBalance = 0) {
this.owner = owner;
this.balance = openingBalance;
}
}
const account = new BankAccount("Asha", 750);
console.log(account.owner, account.balance, account.currency);
console.log(Object.keys(account));
Field initialisers run before the constructor body, in declaration order. The constructor body then overwrites owner and balance with the values it was given, while currency keeps the default because the constructor never mentions it. Every account therefore owns a currency property even though no constructor argument exists for it.
The reason to prefer a declared field over a prototype default is mutability. If you put BankAccount.prototype.currency = "INR" instead, one account changing its currency would change it for everybody. Declared fields keep the value isolated to the object.
What happens when an instance property shadows a prototype property?
It quietly wins, and the original is still there underneath. This is the trap that makes people distrust prototypes: a value you thought was defined for the whole type suddenly behaves like a per-object value.
class BankAccount {
constructor(owner) {
this.owner = owner;
}
describe() {
return this.owner + " - " + this.status;
}
}
BankAccount.prototype.status = "active";
const account = new BankAccount("Asha");
console.log(account.status);
account.status = "closed";
console.log(account.status);
console.log(account.hasOwnProperty("status"));
console.log(BankAccount.prototype.status);
The account starts by finding active on the prototype. The assignment creates an own property on the account that shadows it, so the account now reports closed while the prototype still reports active, and a brand new account would still be active. hasOwnProperty("status") flips to true, which is the cheapest way to spot that a shadow has been created.
Shadowing is not always a bug. Using it to give one object a different value from the rest is legitimate. The problem is doing it by accident, when you meant to change the default for every instance and changed only one.
How do you stop outside code from touching a field?
A name prefixed with a hash — #balance — creates a private field. It is not a convention like an underscore prefix; it is enforced by the language. Outside code cannot read it, write it, or even list it.
class BankAccount {
#balance;
constructor(owner, openingBalance) {
this.owner = owner;
this.#balance = openingBalance;
}
get balance() {
return this.#balance;
}
deposit(amount) {
if (amount <= 0) {
throw new RangeError("deposit must be positive");
}
this.#balance += amount;
return this.#balance;
}
static inspect(account) {
return "internal balance: " + account.#balance;
}
}
const account = new BankAccount("Asha", 500);
account.deposit(250);
console.log(account.balance);
console.log(Object.keys(account));
console.log(Object.getOwnPropertyNames(account));
console.log(BankAccount.inspect(account));
try {
account.deposit(-5);
} catch (err) {
console.log(err.constructor.name + ": " + err.message);
}
The balance reads back as 750 through the getter, but Object.keys(account) lists only owner, and Object.getOwnPropertyNames(account) does not reveal the private field either. A private field is not a property at all — it is a separate slot that only code inside the class body can touch, which is exactly why BankAccount.inspect can read account.#balance even though nothing outside the class can. Attempting to deposit a negative amount throws a RangeError with the message deposit must be positive.
What to remember about constructors and instance state
- The constructor runs once per object, right after
newcreates it, and its job is to decide what that object owns. - Data declared on
thisor in a class field is per object. Methods on the prototype are shared by every instance. - Assigning to an inherited property creates a shadow on the instance rather than changing the type, and
hasOwnPropertyis how you spot it. - A hash-prefixed field is genuinely private: not enumerable, not listable, not writable from outside the class.
- Use a getter to expose a private value for reading, and keep the mutation path inside a method so the rules live in one place.
Try it yourself: build a class Playlist with a declared field tracks = [], a constructor that takes a name and assigns this.name = name, and a method addTrack(track) that pushes onto this.tracks. Create two playlists and confirm that one.tracks === two.tracks is false while one.addTrack === two.addTrack is true. Then add Playlist.prototype.version = 1, set one.version = 2, and check that two.version still prints 1 while one.version prints 2. Finally, convert tracks to a private field #tracks, expose it with a getter, and confirm that Object.keys(one) no longer lists it.