JavaScript Classes: How They Actually Work
The bug was a queue that came out empty. A subclass declared retries = 3, the base constructor read this.retries to size the queue, and it read undefined every time.
Both files were right on their own. What was wrong was the order: a derived class installs its fields after super() returns, so the base constructor had already run and finished.
Class syntax packs a lot of behaviour into a small body, and most of the surprises are timing rather than syntax. This article walks the class body element by element, puts the whole evaluation order into one sequence, and then follows an instance out of your program through JSON.stringify, structuredClone and instanceof.
A class is a function with a prototype attached
Strip the syntax away and a class is a function object with a prototype property attached. typeof says so, and so does everything you can ask about the two halves:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
get magnitude() {
return Math.hypot(this.x, this.y);
}
static origin() {
return new Point(0, 0);
}
}
const p = new Point(3, 4);
console.log(typeof Point);
console.log(Object.keys(p).join(', '));
console.log(Object.getOwnPropertyNames(Point.prototype).join(', '));
console.log(Object.getOwnPropertyDescriptor(Point.prototype, 'magnitude').enumerable);
console.log(typeof Point.origin, typeof p.origin);
function
x, y
constructor, magnitude
false
function undefined
x and y are own properties of the instance because the constructor assigned them. magnitude sits on Point.prototype and reports enumerable: false. p.origin is undefined, because a static member lives on the constructor and an instance never looks there.
That is the whole placement story: a property can live in one of three places, plus a fourth kind of thing that is not a property at all.
- Instance members are own properties of each object, created by a field declaration or by
this.x = …in the constructor. Enumerable, one copy per instance. - Prototype members are methods, getters and setters written without
static. They are installed onC.prototypeand shared by every instance, and MDN records them as writable, non-enumerable and configurable, unlike a method in an object literal, which is enumerable. - Static members are fields, methods and accessors written with
static. They live on the constructor object itself and are invisible from an instance.
Anything written with a # is a private element, and a private element is not a property in any of those three places. The next section takes that up. The chain the three groups hang on is the ordinary one described in Native prototypes.
Three rules apply to the class as a whole. The body is always executed in strict mode, with or without a "use strict" directive. The binding follows let and const rather than function declarations, so it sits in the temporal dead zone and cannot be used before its declaration is evaluated. And the constructor refuses to be called as a plain function:
try {
new Temperature(20);
} catch (err) {
console.log(err.constructor.name);
}
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
}
try {
Temperature(20);
} catch (err) {
console.log(err.constructor.name);
}
ReferenceError
TypeError
The first is the dead zone, the second is the new requirement. Classes also come in expression form, const Temperature = class { … }, which is what the mixins below return. Class basic syntax covers the declaration forms in more detail.
Everything you can put in a class body
The grid of class elements is smaller than it looks: public or private, instance or static, field or method or accessor, plus the constructor and the static initialization block. Here is one class using every square:
class Account {
static #opened = 0; // static private field
static currency = 'EUR'; // static public field
static bank; // static public field
static { // static initialization block
this.bank = 'Ledger';
}
#balance = 0; // private instance field
label = 'unnamed'; // public instance field
constructor(label) {
this.label = label;
Account.#opened++;
}
deposit(cents) { // public prototype method
this.#apply(cents);
return this;
}
#apply(cents) { // private method
this.#balance += cents;
}
get balance() { // public prototype getter
return this.#balance;
}
static get opened() { // static getter
return Account.#opened;
}
}
const savings = new Account('savings');
savings.deposit(1999);
console.log(savings.balance, Account.opened, Account.bank, Account.currency);
1999 1 Ledger EUR
Every form in that body, plus the #x in obj brand check below, shipped together in ECMAScript 2022: public and private instance fields, private methods and accessors, static class features, and static initialization blocks. MDN lists private class properties as Baseline “Widely available”, available across browsers since July 2021, and class static initialization blocks as Baseline “Widely available” since March 2023.
Notice that #apply is commented as a private method, not a private prototype method. It is not a property of Account.prototype, and Object.getOwnPropertyNames will not find it there. It is declared once and reachable from every instance of the class, and that is all you can say about where it lives.
The # is part of the name, not a modifier on it. #balance and balance are two different names that can sit on the same object at once, and the only expressions that can read #balance are the ones written inside the class body that declared it.
That is also the answer to the question everyone asks first. The class fields proposal’s syntax FAQ explains why there is no private keyword: JavaScript has no static type information, so under a private x declaration nothing distinguishes a private this.x from a public this.x, and a small mistake would silently read a different, public property instead.
Three distinct failures fall out of that, and blurring them together is the usual mistake:
counter.#countwritten outside any class body is a SyntaxError. The file never runs.this.#missinginside a class that never declared#missingis also a SyntaxError.- A declared private read against an object that does not carry the element is a TypeError at runtime: “Cannot read private member #x from an object whose class did not declare it”.
Only the third one is something your code can catch, which is why the in operator learned to do brand checks:
class Counter {
#count = 0;
bump() {
this.#count += 1;
return this.#count;
}
static holdsCount(obj) {
return #count in obj;
}
static read(obj) {
return obj.#count;
}
}
const c = new Counter();
c.bump();
console.log(Counter.holdsCount(c), Counter.holdsCount({ count: 1 }));
try {
Counter.read({ count: 1 });
} catch (err) {
console.log(err.constructor.name);
}
console.log(JSON.stringify(c));
true false
TypeError
{}
#count in obj is true only for objects carrying that class’s private field, and it is the safe alternative to wrapping a read in try/catch. The last line is a preview of the boundary section: a private field is not a property, so nothing serializes it.
A static initialization block runs with this bound to the class constructor, and several blocks run in declaration order. Because a block sits inside the class body, it can hand private access out to a closure in the enclosing scope, which is how the friend-class pattern is built.
What actually happens when you write new
Two separate sequences run at two different times, and nearly every class surprise lives in the gap between them.
class declaration time new Derived() time
────────────────────── ──────────────────
1 evaluate `extends` 5 base fields installed
2 create prototype and 6 base constructor body runs
constructor object ◄── the opening bug lives here:
3 install methods and no derived field exists yet
accessors 7 super() returns, derived fields
4 run static fields and installed
static blocks, in order 8 derived constructor body runs
When the class declaration is evaluated, in order: the extends expression is evaluated first, then the prototype and constructor are created and the methods and accessors are installed on them, then static fields and static blocks run in declaration order with this bound to the class.
When you call new, a base class installs its instance fields and then runs the constructor body. A derived class enters its constructor body first, super() runs the parent’s whole sequence, and the derived fields are installed the moment super() returns.
function log(label) {
console.log(label);
return label;
}
class Base {
baseField = log('3. base field');
constructor() {
log('4. base constructor body');
}
}
class Derived extends Base {
static staticField = log('1. static field');
static {
log('2. static block');
}
derivedField = log('5. derived field');
constructor() {
super();
log('6. derived constructor body');
}
}
log('--- classes evaluated, now constructing ---');
new Derived();
1. static field
2. static block
--- classes evaluated, now constructing ---
3. base field
4. base constructor body
5. derived field
6. derived constructor body
Steps 1 and 2 ran before the marker, without anyone calling new. Steps 3 through 6 are the cost of one instantiation, and the gap between 4 and 5 is where the opening bug lives:
class Base {
constructor() {
console.log('Base constructor:', this.count);
}
}
class Derived extends Base {
count = 1;
constructor() {
super();
console.log('Derived constructor:', this.count);
}
}
new Derived();
Base constructor: undefined
Derived constructor: 1
The base constructor is not broken and the field is not misspelled. It does not exist yet. If the parent needs the value, pass it up as a super() argument.
Two smaller consequences of the same sequence. Field initializers are evaluated once per instance, so class C { obj = {} } gives every instance a different object, and instance1.obj === instance2.obj is false. And the initializer expression is evaluated synchronously: you cannot use await or yield in a field initializer or in a static block.
Private methods are usable from a field initializer even when they are declared further down the body, because they are attached to the instance before any field initializer runs:
class Report {
title = this.#format('quarterly');
#format(name) {
return name.toUpperCase();
}
}
console.log(new Report().title);
QUARTERLY
Now the part no reference page puts next to the timeline. Fields are added using the [[DefineOwnProperty]] semantic, essentially Object.defineProperty(), and defining a property does not consult the prototype chain. So a field declaration in a derived class does not invoke a setter in the base class, and this.field = … in the constructor does:
class Base {
set size(value) {
console.log('base setter ran:', value);
this._size = value;
}
get size() {
return this._size;
}
}
class WithField extends Base {
size = 10;
}
class WithAssignment extends Base {
constructor() {
super();
this.size = 10;
}
}
const a = new WithField();
const b = new WithAssignment();
console.log(Object.hasOwn(a, 'size'), a._size);
console.log(Object.hasOwn(b, 'size'), b._size);
base setter ran: 10
true undefined
false 10
One log line, from the class that used assignment. WithField produced an own size property that shadows the inherited accessor and left _size untouched, and no error told you so.
Inheritance: extends, super, and what single inheritance costs
extends builds two chains, not one, and they are easy to conflate:
class Base {
static tag = 'base';
ping() {
return 'ping';
}
}
class Derived extends Base {}
const d = new Derived();
console.log(Object.getPrototypeOf(d) === Derived.prototype);
console.log(Object.getPrototypeOf(Derived.prototype) === Base.prototype);
console.log(Object.getPrototypeOf(Derived) === Base);
console.log(d.ping(), Derived.tag);
true
true
true
ping base
The instance chain runs instance → Derived.prototype → Base.prototype, which is how d.ping() resolves. The constructor chain runs Derived → Base, which is how Derived.tag finds a static declared on the parent. Class inheritance works through the chain in detail.
In a derived constructor, super() must run before any use of this, and the engine enforces it:
class Base {}
class Derived extends Base {
constructor() {
try {
this.ready = true;
} catch (err) {
console.log(err.constructor.name);
}
super();
}
}
new Derived();
ReferenceError
Inside a prototype method, super.method() reaches the parent’s version, which is how you override something and still call through to it. It works in a field initializer too: there, super refers to the base class’s prototype property, so it reaches the base class’s instance methods but not its instance fields. And new.target is the constructor new was applied to, which is how a base class refuses direct instantiation: if (new.target === Shape) throw new TypeError('Shape is abstract').
Private elements are not inherited, and one case of that catches people. A static private read through this from a subclass throws, because this is the subclass and the subclass does not carry the element:
class Registry {
static #items = [];
static add(item) {
this.#items.push(item);
return this.#items.length;
}
static addSafely(item) {
Registry.#items.push(item);
return Registry.#items.length;
}
}
class Plugins extends Registry {}
console.log(Registry.add('a'));
try {
Plugins.add('b');
} catch (err) {
console.log(err.constructor.name);
}
console.log(Plugins.addSafely('b'));
1
TypeError
2
Reach static private state through the class name, never through this.
A class extends exactly one class, and the standard way out is a mixin: a function that takes a base class and returns a subclass of it.
const Serializable = (Base) =>
class extends Base {
toJSON() {
return { ...this, type: this.constructor.name };
}
};
class Shape {
constructor(name) {
this.name = name;
}
}
class Circle extends Serializable(Shape) {
radius = 2;
}
const c = new Circle('circle');
console.log(JSON.stringify(c));
console.log(c instanceof Shape, c instanceof Circle);
{"name":"circle","radius":2,"type":"Circle"}
true true
The mixin inserts a real class into the chain, so instanceof keeps working in both directions. Mixins goes further into composing several of them.
Methods, fields, and the this problem
A prototype method is not bound to anything. Pull it off the instance and the receiver goes with it, and because the class body is strict mode, this is undefined rather than the global object:
class Greeter {
name = 'Maya';
greet() {
return `hello, ${this.name}`;
}
}
const g = new Greeter();
const loose = g.greet;
try {
loose();
} catch (err) {
console.log(err.constructor.name);
}
console.log(g.greet.call({ name: 'Raj' }));
TypeError
hello, Raj
There are three fixes in common use, and they do not cost the same:
class Row {
label = 'Save';
renderBound;
renderArrow = () => this.label; // (3)
constructor() {
this.renderBound = this.render.bind(this); // (2)
}
render() { // (1)
return this.label;
}
}
const row = new Row();
console.log(Object.keys(row).join(', '));
console.log(Object.hasOwn(row, 'render'), 'render' in row);
console.log(JSON.stringify(row));
console.log(new Row().renderArrow === row.renderArrow);
label, renderBound, renderArrow
false true
{"label":"Save"}
false
renderis a prototype member. One function object exists for the whole class, it is non-enumerable, andObject.keysdoes not see it. The fix at the call site is an arrow wrapper:onClick={() => row.render()}. Nothing about the class changes.renderBoundis an instance member holding a bound copy ofrender. One extra function object per instance, and an enumerable own property.renderArrowis also an instance member. Same cost as (2), with the binding built into the arrow instead of applied afterwards.
The last line of output is the cost, stated plainly: two instances have two different renderArrow functions. The first three lines are the consequence you actually trip over. Both (2) and (3) put an enumerable own property on every instance, so they appear in Object.keys, in object spread and in anything that copies own properties. JSON.stringify drops them, because function values are omitted when found in an object. And a function stored in a field is not on the prototype, so super.render() in a subclass cannot reach it. Object Layout and What Memory Really Costs covers what per-instance properties do further down.
What survives the boundary: JSON, structuredClone, instanceof
An instance is a rich thing inside your program and a much poorer thing the moment it crosses a boundary. The structured clone algorithm is not only the structuredClone function: it runs whenever you postMessage to a worker, write to IndexedDB, call history.pushState, or send down a BroadcastChannel. Here is the same class going through both of the common ones:
class Session {
id = 'a1';
#token = 'secret';
static realm = 'app';
get age() {
return 0;
}
touch() {
return this.id;
}
}
const s = new Session();
console.log(JSON.stringify(s));
const clone = structuredClone(s);
console.log(clone.constructor.name, typeof clone.touch, Object.keys(clone).join(', '));
try {
structuredClone({ send: () => 1 });
} catch (err) {
console.log(err.name);
}
{"id":"a1"}
Object undefined id
DataCloneError
JSON.stringify visits only enumerable own properties, which is exactly the instance-member group and nothing else. The structured clone algorithm does not walk or duplicate the prototype chain, so the clone comes back as a plain Object with no methods, and it does not duplicate class private elements either. The third line is why the arrow-function field from the previous section matters here: Function objects cannot be cloned, and one of them anywhere in the graph throws a DataCloneError for the whole call.
| What you wrote | JSON.stringify | structuredClone |
|---|---|---|
| public field, data value | serialized | cloned |
| public field, function value | key omitted | throws DataCloneError |
| prototype method or accessor | not visited | not cloned, prototype not walked |
| static member | not visited, not on the instance | not cloned, not on the instance |
| private field | never | not duplicated |
Two more details on the JSON side. Symbol-keyed properties are ignored entirely, and undefined, Function and Symbol values are omitted when found in an object, while inside an array they become null. A toJSON() method, if present, decides what gets serialized instead of all of that:
class Money {
cents = 0;
get formatted() {
return `$${(this.cents / 100).toFixed(2)}`;
}
toJSON() {
return { cents: this.cents };
}
static from(data) {
return Object.assign(new Money(), data);
}
}
const price = Money.from({ cents: 1999 });
console.log(price.formatted);
console.log(JSON.stringify({ price }));
const roundTrip = JSON.parse(JSON.stringify(price));
console.log(roundTrip.formatted);
console.log(Money.from(roundTrip).formatted);
$19.99
{"price":{"cents":1999}}
undefined
$19.99
JSON.parse hands back a plain object, so the getter is gone and formatted is undefined. Rehydrating means putting the prototype back, either with Object.assign(new Money(), data) or with a static factory that validates on the way in.
instanceof has its own boundary. It compares against one specific prototype object, so it fails across realms: [] instanceof window.frames[0].Array is false, because the frame has its own Array.prototype. That is the failure behind “this array is not an array” in iframe and worker code, and the #x in obj brand check does not rescue you: private names are created per class evaluation, so the frame’s own copy of a class carries its own set and the check comes back false for exactly the same reason. What does cross a realm is Array.isArray, Object.prototype.toString.call(x), a structural check on the shape you actually need, or a Symbol.hasInstance override. In the other direction, a class can take over the operator entirely with Symbol.hasInstance:
class Duck {
static [Symbol.hasInstance](obj) {
return typeof obj?.quack === 'function';
}
}
console.log({ quack() {} } instanceof Duck);
console.log({} instanceof Duck);
true
false
Decorators, accessor, and the tooling gap
The status first, because plenty of posts get it wrong. The TC39 proposals list places Decorators at Stage 2.7, with Kristen Hewell Garrett as champion, so it is not a finished proposal. caniuse records the feature as unsupported in every browser and notes that decorators are supported only by transpiler tools. Every decorator you have shipped was compiled away by TypeScript or Babel before an engine saw it.
The shape is worth knowing anyway, because it is stable enough that tooling has settled on it. A decorator is a function that receives the decorated value plus a context object, and it may return a replacement value. The proposal also adds the accessor keyword, an auto-accessor that defines a getter and setter pair on the prototype over private backing storage.
function logged(value, context) {
return function (...args) {
console.log(`calling ${String(context.name)}`);
return value.call(this, ...args);
};
}
class Order {
@logged
submit() {
return 'submitted';
}
accessor total = 0;
}
Paste that into a browser console today and you get a syntax error, so you need a compiler. TypeScript 5.0 and later implement the standard proposal when experimentalDecorators is off, which is the default; the legacy flag selects the old, incompatible design instead. Babel needs @babel/plugin-proposal-decorators with version set to "2023-11". Decorator expressions are also restricted by the grammar to variable chains, property access and calls, with @(expression) as the escape hatch when you need something else.
One tooling detail breaks real projects. TypeScript’s useDefineForClassFields defaults to “true if target is ES2022 or higher, including ESNext; false otherwise”, and turning it on switches class field emit to the standard define semantics from the timeline section. That is the same [[DefineOwnProperty]] behaviour, with the same consequence:
class Base {
constructor() {
this.x = 5;
}
}
class Derived extends Base {
x;
}
console.log(new Derived().x);
undefined
A declaration with no initializer still defines the property, as undefined, after super() returns. If your code depends on a field declaration leaving alone whatever a base constructor or a decorator put there, define semantics will overwrite it, and setting the flag to false returns to the older emit, which splits the two cases: a declaration with no initializer emits nothing at all, so the 5 from the base constructor survives, while a field with an initializer becomes a this.x = … assignment, which does fire an inherited setter.
When a class is the right tool
MDN’s own guidance is that whether to use classes is a design decision rather than a requirement, and the rule that follows from everything above is a short one.
Reach for a class when four things are true at once: the objects have identity, they own mutable internal state, their behaviour is bound to that state, and they vary by subtype. The built-ins are the model here. Map, Set, Date and Error all hold internal data with rich behaviour over it, which is what class syntax is shaped for.
Miss two of those and something lighter reads better. If you are transforming data rather than owning it, a module of plain functions over plain objects has no this to lose and no prototype to reattach after a round trip through JSON. If you need one object with captured state and no subtyping, a closure does it without a prototype chain at all, and Variable scope, closure covers that shape. If you need construction logic and validation more than identity, Factory Functions and the Factory Pattern is the other door.
What a class gets for free, where a constructor function has to be built by hand, is a short list, and it is the reason “syntactic sugar” undersells the syntax: private elements with real enforcement, a constructor that throws when called without new, a binding in the temporal dead zone, and a body that is always strict. Each has a manual equivalent — WeakMaps, a new.target guard, a const binding, "use strict" — and the one that genuinely resists hand-rolling is correct subclassing of built-ins like Array, where super() is what allocates the exotic object. Everything else in this article, the prototype chain, the two inheritance chains, instanceof, is the prototypal machinery that was already there, given a body to be written in.
If you want that path in order rather than across forty tabs, Part 1: JavaScript Fundamentals is where the course starts.
Sources:
- MDN: Classes
- MDN: Public class fields
- MDN: Private properties
- MDN: Static initialization blocks
- MDN: Method definitions
- MDN: JSON.stringify()
- MDN: The structured clone algorithm
- MDN: instanceof
- MDN: Using classes
- TC39 finished proposals
- TC39 proposals
- proposal-class-fields private syntax FAQ
- proposal-decorators
- caniuse: decorators
- V8: public and private class fields
Frequently asked questions
Are JavaScript classes just syntactic sugar for prototypes?
C.prototype and builds the same prototype chain a constructor function would, which is why MDN frames classes primarily as an abstraction over prototypal inheritance. Private elements, the rule that a class constructor throws when called without new, the temporal dead zone on the binding, and the always-strict class body have no exact constructor-function equivalent.Why is my class field undefined inside the parent constructor?
super() returns, so the base constructor finishes before any derived field exists. Pass the value up through super() as a constructor argument, or read it in a method called after construction.Why don't class methods appear in JSON.stringify output?
JSON.stringify visits only enumerable own properties. Methods declared in a class body live on C.prototype and are non-enumerable, so they are neither own nor enumerable, and private fields are not properties at all. Public instance fields do serialize, and a toJSON() method takes over the whole decision if you define one.Are JavaScript decorators standard yet?
Should I use an arrow function class field or a normal method?
Object.keys and in object spread, JSON.stringify drops it because function values are omitted from objects, and structuredClone throws a DataCloneError on the whole instance. Use the arrow field when you need a permanently bound callback and can accept that.