JavaScript Classes: How They Actually Work

Aug 10, 2026·26 min read

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 on C.prototype and 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.#count written outside any class body is a SyntaxError. The file never runs.
  • this.#missing inside a class that never declared #missing is 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.

size = 10field declaration, [[DefineOwnProperty]]the instancesize: 10own propertyBase.prototypeset size(v)not consulteddefineshadows the accessor, _size stays undefinedthis.size = 10constructor assignment, [[Set]]the instance_size: 10no own sizeBase.prototypeset size(v)found and runsetthe setter runs, _size becomes 10
A field declaration defines on the instance; an assignment walks up and finds the setter.

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.prototypeBase.prototype, which is how d.ping() resolves. The constructor chain runs DerivedBase, which is how Derived.tag finds a static declared on the parent. Class inheritance works through the chain in detail.

instance chainconstructor chaindthe instanceDerived.prototypeDerivedBase.prototypeping()BasetagObject.prototypeFunction.prototypesolid arrow = the [[Prototype]] linkdashed arrow = the .prototype propertyd.ping() walks the left chain, Derived.tag the right
extends builds two chains: instance lookups walk the left one, static lookups the right.

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
  1. render is a prototype member. One function object exists for the whole class, it is non-enumerable, and Object.keys does not see it. The fix at the call site is an arrow wrapper: onClick={() => row.render()}. Nothing about the class changes.
  2. renderBound is an instance member holding a bound copy of render. One extra function object per instance, and an enumerable own property.
  3. renderArrow is 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.

Row.prototyperendernot enumerable[[Prototype]]instance 1renderArrowits own copyinstance 2renderArrowits own copyrender: one function, shared by every instancerenderArrow: one per instance, enumerable own
One shared prototype method, versus one arrow-function field per instance.

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 wroteJSON.stringifystructuredClone
public field, data valueserializedcloned
public field, function valuekey omittedthrows DataCloneError
prototype method or accessornot visitednot cloned, prototype not walked
static membernot visited, not on the instancenot cloned, not on the instance
private fieldnevernot 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:

Frequently asked questions

Are JavaScript classes just syntactic sugar for prototypes?
Mostly, but not entirely. A class installs its methods on 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?
Because of when fields are installed. In a base class, fields are added before the constructor body runs, but in a derived class they are added just after 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?
Not yet. The TC39 proposals list places Decorators at Stage 2.7 with Kristen Hewell Garrett as champion, and caniuse records no native support in any browser. Every decorator running in your code today was compiled away by TypeScript or Babel before an engine saw it.
Should I use an arrow function class field or a normal method?
A normal method is one shared, non-enumerable function on the prototype. An arrow function field is a separate function object on every instance stored as an enumerable own property, so it shows up in 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.