Classes are sugar — but useful sugar
JavaScript's class keyword, added in ES2015, gives an object-oriented syntax that feels
familiar to developers from Java, C#, or Python. But it's crucial to understand the headline
fact up front: classes are syntactic sugar over constructor functions and prototypes.
There's no new object model underneath — class just makes the existing prototype machinery
cleaner and less error-prone to write. Knowing this keeps you grounded: every class feature
maps to something you could write by hand with functions and prototype.
That said, the sugar is genuinely valuable. It standardizes patterns, adds real encapsulation (private fields), and eliminates entire categories of bugs from the old manual-wiring days.
Declaring a class
A class declaration bundles a constructor and methods into one block.
class Circle {
constructor(radius) {
this.radius = radius // instance field set in constructor
}
area() {
return Math.PI * this.radius ** 2
}
}
const c = new Circle(5)
c.area() // 78.54...
The constructor runs when you call new Circle(5) and initializes instance state. The
area method is not copied onto each instance — it lives on Circle.prototype, exactly
as if you'd written Circle.prototype.area = function () {...}. You can verify it:
Object.getPrototypeOf(c) === Circle.prototype // true
c.hasOwnProperty('area') // false — it's on the prototype
Class declarations vs expressions
Like functions, classes come in declaration and expression forms.
class A {} // declaration
const B = class {} // anonymous class expression
const C = class Named {} // named class expression
One important difference from function declarations: classes are not hoisted in a usable way. They live in a "temporal dead zone" until the declaration is evaluated, so you can't use a class before you define it.
new Foo() // ReferenceError — TDZ
class Foo {}
This stricter behavior is intentional — it prevents the confusing "use before define" that function hoisting allows.
Instance fields
Beyond setting fields in the constructor, you can declare class fields directly in the class body. They're initialized on each instance before the constructor body runs.
class Counter {
count = 0 // instance field with a default
step = 1
increment() {
this.count += this.step
return this.count
}
}
new Counter().increment() // 1
Field declarations make defaults explicit and readable, and they're set per-instance (unlike
methods, which are shared). A subtle point: a field initializer that references this runs
in field-declaration order, so later fields can build on earlier ones.
Methods, getters, and setters
Methods defined in the class body land on the prototype. You can also define accessor
properties with get and set, which look like fields but run functions.
class Temperature {
constructor(celsius) { this.celsius = celsius }
get fahrenheit() { return this.celsius * 9 / 5 + 32 }
set fahrenheit(f) { this.celsius = (f - 32) * 5 / 9 }
}
const t = new Temperature(20)
t.fahrenheit // 68 — getter
t.fahrenheit = 212 // setter
t.celsius // 100
Getters and setters are ideal for derived values and validation. Like methods, they live on the prototype, so all instances share the accessor functions.
Computed method names
Method names can be computed with brackets, just like object literal keys — useful for dynamic or symbol-based methods.
const sym = Symbol('run')
const methodName = 'go'
class Machine {
[methodName]() { return 'going' } // computed string name
[sym]() { return 'running' } // symbol-keyed method
*[Symbol.iterator]() { yield 1; yield 2 } // makes instances iterable
}
Defining [Symbol.iterator] as a generator method is the idiomatic way to make a class's
instances work with for...of and spread.
this inside methods
A method's this is the instance it was called on — but only if you call it as a method.
Detach it and this is lost, the same gotcha as with any function.
class Button {
constructor(label) { this.label = label }
click() { return `${this.label} clicked` }
}
const b = new Button('OK')
const fn = b.click
fn() // TypeError — `this` is undefined (class bodies are strict mode)
Two common fixes: bind in the constructor (this.click = this.click.bind(this)) or use a
field with an arrow function, which captures this lexically.
class Button {
constructor(label) { this.label = label }
click = () => `${this.label} clicked` // arrow field — `this` always bound
}
const b = new Button('OK')
const fn = b.click
fn() // 'OK clicked' — works detached
The tradeoff: arrow-function fields are created per instance (not shared on the prototype), costing a bit more memory. For event handlers that get passed around, that's usually a worthwhile trade.
Strict mode by default
Everything inside a class body runs in strict mode automatically, regardless of the
surrounding code. That's why detached methods get undefined for this instead of the
global object, and why silent failures (like writing to a read-only property) become loud
errors. This stricter default catches bugs early.
Classes must be called with new
Unlike old constructor functions, a class throws if you forget new.
class User { constructor(name) { this.name = name } }
User('Ada') // TypeError: Class constructor User cannot be invoked without 'new'
This is a feature: it eliminates the silent forgotten-new bug that plagued constructor
functions. There's no way to accidentally pollute the global object with a class.
What it compiles to
To cement the "sugar" point, here's the rough equivalent of a simple class written the old way:
// class Circle { constructor(r){ this.r = r } area(){ return Math.PI*this.r**2 } }
function Circle(r) {
'use strict'
this.r = r
}
Circle.prototype.area = function () { return Math.PI * this.r ** 2 }
The class version adds the must-use-new guard, strict mode, and cleaner syntax — but the
prototype-based result is identical. Whenever class behavior puzzles you, mentally translate
it back to constructors and prototypes and the answer usually appears.
Key takeaways
classis syntactic sugar over constructor functions and prototypes — no new object model.- Methods, getters, and setters live on the prototype and are shared; instance fields are per-instance.
- Class bodies are always strict mode, and classes can't be used before declaration (TDZ).
thisfollows normal rules — detached methods lose it; arrow-function fields capture it lexically at a per-instance memory cost.- Classes throw if called without
new, eliminating the forgotten-newbug. - Computed names and
[Symbol.iterator]generator methods enable dynamic and iterable classes.
Treat class as a clean, safer wrapper over the prototype system you already understand, and
its every behavior becomes predictable rather than magical.