JavaScript Classes

5 minute read

JavaScript classes are syntactic sugar over its prototype model — but the syntax is the kind of sugar that completely changes how you write code. Classes give you constructors, methods, static members, getters/setters, and (since 2022) real private fields. This chapter covers all of it, plus when you shouldn’t reach for a class at all.

Defining a Class

class User {
  constructor(name, email) {
    this.name  = name;
    this.email = email;
  }

  greet() {
    return `Hi, I'm ${this.name}`;
  }
}

const u = new User("Ada", "ada@example.com");
console.log(u.greet());            // Output: Hi, I'm Ada
console.log(u instanceof User);    // Output: true

Three things going on:

  • constructor runs on new — sets up instance state via this
  • Plain methods (no function, no commas between them) live on User.prototype
  • new allocates the object and wires up the prototype chain

Class Expressions

Classes can be expressions, just like functions:

const User = class {
  constructor(name) { this.name = name; }
};

// Named class expression — name is only visible inside
const Logger = class L {
  log(msg) { console.log(`[${L.name}]`, msg); }
};

Useful for factories or one-off helpers.

Class Fields (Public)

You can declare fields outside the constructor — they initialise on each instance:

class Counter {
  count = 0;                       // public field

  increment() { this.count++; }
}

const c = new Counter();
c.increment();
console.log(c.count);              // Output: 1

Field initialisers can reference this and run after the implicit super() call.

Private Fields — #name

True private state, enforced by the language (not a convention):

class BankAccount {
  #balance = 0;                    // private

  deposit(amount) {
    if (amount <= 0) throw new Error("must be positive");
    this.#balance += amount;
  }

  get balance() { return this.#balance; }
}

const acct = new BankAccount();
acct.deposit(100);
console.log(acct.balance);         // Output: 100
console.log(acct.#balance);        // ❌ SyntaxError — outside class

#name is not the same as _name. It’s lexically scoped to the class body and cannot be read or written from outside, not even with bracket notation.

Convention Enforced? Visible outside?
obj._name No (just a naming hint) Yes
obj.#name Yes (syntax error otherwise) No

Getters and Setters

Computed properties that look like fields:

class Temperature {
  #celsius;

  constructor(c) { this.#celsius = c; }

  get celsius()    { return this.#celsius; }
  set celsius(v)   { if (v < -273.15) throw new RangeError("absolute zero"); this.#celsius = v; }

  get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
  set fahrenheit(f){ this.celsius = (f - 32) * 5 / 9; }
}

const t = new Temperature(25);
console.log(t.fahrenheit);         // Output: 77
t.fahrenheit = 100;
console.log(t.celsius);            // Output: 37.777...

Use getters/setters to:

  • Validate on assignment
  • Expose computed values (no parentheses at call site)
  • Wrap legacy attribute names without breaking callers

Static Members

Belong to the class itself, not instances:

class MathUtil {
  static PI = 3.14159;

  static square(n) { return n * n; }

  static #counter = 0;             // static private
  static next() { return ++MathUtil.#counter; }
}

console.log(MathUtil.PI);          // Output: 3.14159
console.log(MathUtil.square(5));   // Output: 25
console.log(MathUtil.next());      // Output: 1

Common static patterns:

  • Constants: class Color { static RED = "#f00"; }
  • Factory methods: Date.now(), URL.canParse(s)
  • Helpers: Array.isArray()

Static Initialisation Blocks (ES2022)

For more complex one-time setup:

class Config {
  static defaults;

  static {
    Config.defaults = loadFromDisk();
    console.log("Config initialised");
  }
}

Runs once, when the class is evaluated.

Methods vs Arrow Function Fields

class Button {
  label = "Click";

  handleClick() {                  // method — `this` depends on caller
    console.log(this?.label);
  }

  handleClickArrow = () => {       // arrow field — `this` always bound
    console.log(this.label);
  };
}

const b = new Button();
const m1 = b.handleClick;
const m2 = b.handleClickArrow;

m1();                              // Output: undefined  (this lost)
m2();                              // Output: Click       (this captured)

Trade-off:

  Method (on prototype) Arrow field
Where stored Once on Class.prototype Once per instance
this when detached Lost Preserved
Memory Cheaper More per instance
Inheritance / super Works No super

Tip: Use methods by default. Reach for arrow-field methods only when you’ll regularly pass them as event handlers or callbacks where binding matters.

How Classes Map to Prototypes

class is sugar. Behind the scenes:

class User {                    function User(name) { this.name = name; }
  constructor(name) {           User.prototype.greet = function() {
    this.name = name;             return `Hi ${this.name}`;
  }                             };
  greet() { return … }
}

u.greet() works because Object.getPrototypeOf(u) === User.prototype. Knowing this helps when you read older code or debug instanceof.

instanceof and Duck Typing

const u = new User("Ada");
u instanceof User;                 // true
u instanceof Object;               // true (everything is)

typeof u;                          // "object"

// Realm caveat: instanceof can lie across iframes / vm contexts.
// For cross-realm tests prefer:
Array.isArray(arr);

A Realistic Example — A Stopwatch

class Stopwatch {
  #startedAt = null;
  #elapsed   = 0;

  start() {
    if (this.#startedAt) return;       // already running
    this.#startedAt = performance.now();
  }

  stop() {
    if (!this.#startedAt) return;
    this.#elapsed  += performance.now() - this.#startedAt;
    this.#startedAt = null;
  }

  reset() {
    this.#startedAt = null;
    this.#elapsed   = 0;
  }

  get isRunning() { return this.#startedAt !== null; }

  get ms() {
    const live = this.#startedAt ? performance.now() - this.#startedAt : 0;
    return this.#elapsed + live;
  }

  toString() { return `${(this.ms / 1000).toFixed(2)}s`; }
}

const sw = new Stopwatch();
sw.start();
// … do work …
sw.stop();
console.log(`Took ${sw}`);             // Output: Took 1.23s

Encapsulation (private state), computed properties (getters), and clear lifecycle methods — exactly what classes are for.

When Not to Use a Class

  • Plain data? Use an object literal { name, age }
  • One-off function? Use a function, not a class with a single method
  • Need composition over inheritance? Plain functions + closures are usually clearer
  • Want immutability? Pure functions over object methods

Don’t reach for class just because other languages would.

Common Mistakes

// ❌ Forgetting `new`
const u = User("Ada");             // TypeError — must be called with new

// ❌ Method declared with comma
class A {
  one() {}, two() {}               // SyntaxError
}

// ❌ Mistaking _underscore for private
class A { _secret = 1; }
new A()._secret;                   // 1 — still public

// ❌ Calling `this` in static method
class A { static x = 1; static f() { return this.x; } }
A.f();                             // 1 — works, but `this` is the class

// ❌ Mutating shared array on prototype
class A { tags = []; }
class B { tags  = []; }            // ✅ each instance gets its own

Best Practices Checklist

  • ✅ Use class when you have state + behaviour that travels together
  • ✅ Use #name for true private fields — not _name
  • ✅ Use getters/setters for validation and computed properties
  • ✅ Use static for constants and pure helpers belonging to the type
  • ✅ Use methods by default; arrow-field methods only when binding matters
  • ✅ Always call with new
  • ❌ Don’t reach for classes for plain data (use objects) or pure logic (use functions)
  • ❌ Don’t store mutable defaults ([], {}) on the prototype

What’s Next?

A single class is rarely the end of the story. Next we extend classes with inheritance: extends, super, method overriding, and when composition is a better choice.

← Previous: JS Error Handling Next: JS Inheritance →

Updated: