JavaScript Classes — Syntax, Inheritance, and Modern Features

A complete tour of ES6 classes — constructors, instance and static members, inheritance with extends and super, private fields and methods, getters and setters, and how every class still sits on top of the prototype chain.

Classes are sugar — but very useful sugar

The class keyword arrived in ES2015 and has been steadily extended ever since. Under the hood it is still the prototype model from the previous post — but the syntax is cleaner, the semantics are stricter, and modern additions (private fields, static blocks) genuinely solve real problems.

Foundations: Objects — the basics, Prototypes and the chain.

A complete class at a glance

 1class Account {
 2  #balance;                            // private field, brand-checked
 3
 4  static minimumDeposit = 1;           // static (class-level) field
 5
 6  constructor(owner, opening = 0) {
 7    this.owner    = owner;
 8    this.#balance = opening;
 9  }
10
11  // Instance method — lives on Account.prototype
12  deposit(amount) {
13    if (amount < Account.minimumDeposit) throw new RangeError("too small");
14    this.#balance += amount;
15    return this;
16  }
17
18  // Getter — accessed without ()
19  get balance() {
20    return this.#balance;
21  }
22
23  // Static method — called on the class itself
24  static from(plain) {
25    return new Account(plain.owner, plain.balance);
26  }
27}
28
29const a = Account.from({ owner: "Ali", balance: 100 });
30a.deposit(20).deposit(5);
31a.balance;   // 125

That single example uses every feature this post covers.

Constructors and instance fields

The constructor runs when new ClassName(...) is called. It is the right place to assign instance state.

Class-field syntax (ES2022) lets you declare instance properties outside the constructor — handy when the default value does not depend on arguments:

1class Counter {
2  count = 0;          // class field — initialised on every instance
3  step  = 1;
4
5  inc() { this.count += this.step; }
6}

Class fields are defined, not assigned — which means they always create an own property and never trigger inherited setters.

Methods live on the prototype

1class Dog { bark() { return "woof"; } }
2const rex = new Dog();
3
4rex.bark === Dog.prototype.bark;   // true
5Object.getPrototypeOf(rex) === Dog.prototype;   // true

This is why adding a method to a class affects every existing instance — instances do not own the method; they look it up on Dog.prototype each call.

Getters and setters

Computed properties that look like fields from the outside:

 1class Temperature {
 2  constructor(celsius) { this._c = celsius; }
 3
 4  get fahrenheit()        { return this._c * 9 / 5 + 32; }
 5  set fahrenheit(f)       { this._c = (f - 32) * 5 / 9; }
 6}
 7
 8const t = new Temperature(20);
 9t.fahrenheit;          // 68
10t.fahrenheit = 100;
11t._c;                  // ~37.8

Getters and setters are the cleanest way to add validation or derived values without changing the call sites.

Static members

static puts a property on the class itself, not on instances. Use it for factories, constants, and utility methods that conceptually belong to the type:

 1class Point {
 2  constructor(x, y) { this.x = x; this.y = y; }
 3
 4  static origin = new Point(0, 0);
 5
 6  static distance(a, b) {
 7    return Math.hypot(a.x - b.x, a.y - b.y);
 8  }
 9
10  static {
11    // Static initialisation block (ES2022) — runs once when the class is defined.
12    // Perfect for set-up that needs multiple statements.
13    console.log("Point class loaded");
14  }
15}
16
17Point.distance(Point.origin, new Point(3, 4));   // 5

Private members

Prefix with # to make a field, method, getter, setter, or static member truly private. The privacy is enforced by the engine — no Reflect, no Object.keys, no debugger trick can reach it from outside the class body.

 1class PinPad {
 2  #pin;
 3
 4  constructor(pin) { this.#pin = pin; }
 5
 6  #hash(s) { return s.split("").reverse().join(""); }   // private method
 7
 8  check(input) {
 9    return this.#hash(input) === this.#hash(this.#pin);
10  }
11}
12
13const p = new PinPad("1234");
14p.check("1234");   // true
15p.#pin;            // SyntaxError at parse time

Private members entirely replace the old _underscore convention. They also make #field in obj a fast brand check — true only for instances of the class that declared #field.

Inheritance with extends and super

 1class Animal {
 2  constructor(name) { this.name = name; }
 3  speak() { return `${this.name} makes a sound`; }
 4}
 5
 6class Dog extends Animal {
 7  constructor(name, breed) {
 8    super(name);            // MUST be called before using `this`
 9    this.breed = breed;
10  }
11
12  speak() {
13    return `${super.speak()} — specifically, a bark`;
14  }
15}
16
17const rex = new Dog("Rex", "Labrador");
18rex.speak();   // "Rex makes a sound — specifically, a bark"
  • extends links the child’s prototype to the parent’s prototype.
  • super(...) invokes the parent constructor; required before this is accessible.
  • super.method() calls the parent’s version, useful for extending instead of replacing.

Extending built-ins

You can extend Array, extends Error, extends Map, etc.:

1class CaseInsensitiveSet extends Set {
2  add(value) { return super.add(String(value).toLowerCase()); }
3  has(value) { return super.has(String(value).toLowerCase()); }
4}

instanceof and brand checks

1const rex = new Dog("Rex");
2rex instanceof Dog;     // true
3rex instanceof Animal;  // true
4rex instanceof Object;  // true

instanceof walks the prototype chain, exactly as covered in the previous post.

For private fields, the in operator is the modern, optimised brand check:

1class Account { #balance; static is(x) { return #balance in x; } }
2Account.is(new Account());   // true
3Account.is({});              // false

Composition vs inheritance

Deep inheritance hierarchies age badly. Where possible, prefer composition — give an object what it needs by combining smaller objects:

 1class Cart {
 2  constructor(taxCalculator, shippingCalculator) {
 3    this.tax      = taxCalculator;
 4    this.shipping = shippingCalculator;
 5  }
 6  total(items) {
 7    const subtotal = items.reduce((s, it) => s + it.price, 0);
 8    return subtotal + this.tax.for(subtotal) + this.shipping.for(items);
 9  }
10}

Each helper can be swapped or tested in isolation, and the Cart class never grows a tangle of subclasses.

Summary

  • class is sugar over prototypes — useful, strict sugar with real new features.
  • Use class fields for default state, #fields for true privacy, getters/setters for derived properties.
  • static for class-level members; static { } blocks for multi-line initialisation.
  • extends + super(...) for inheritance; always call super before this in the constructor.
  • Prefer composition over deep inheritance trees.

The series ends with Modern object features — descriptors, freeze/seal, symbols, iterators, and structured cloning.