JavaScript Classes Explained: Constructors, Methods, Inheritance, and the Desugaring Story
JavaScript got class syntax in ES2015. Despite the keyword, classes in JavaScript are not what they are in Java or C# — they're a thin layer of syntax over the prototype-based inheritance JavaScript already had. Knowing what class desugars to is the difference between using it well and being surprised by it.
This lesson covers everything you need to know about modern JS classes: how to define one, how constructor and methods work, what super does, the difference between instance and static members, and the prototype-chain mechanics underneath. We touched on prototypes briefly in Lesson 4.1; this lesson is about the surface syntax that hides most of that complexity.
A minimal class #
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hi, ${this.name}`;
}
}
const u = new User('Ada');
u.greet(); // 'Hi, Ada'
Four concepts in one example:
class User { }— declares a class. The body contains methods and (with newer syntax) fields.constructor(name)— runs when you callnew User(...). Sets up instance properties.greet()— a method. Lives onUser.prototype, not on each instance.new User('Ada')— creates an instance.newis what links the instance toUser.prototype.
What class actually creates #
A class is a special function. Underneath:
class User {
constructor(name) { this.name = name; }
greet() { return 'Hi, ' + this.name; }
}
// Approximate ES5 equivalent
function User(name) { this.name = name; }
User.prototype.greet = function () { return 'Hi, ' + this.name; };
Key differences from a plain function:
- You must call a class with
new.User('Ada')(nonew) throws. - Class methods are non-enumerable (won't show in
Object.keysof an instance — we covered this in Lesson 3.4). - The class body runs in strict mode automatically.
- A class declaration is not hoisted like a function declaration — it's TDZ-bound like
let.
Instance fields #
Modern syntax lets you declare fields outside the constructor:
class Counter {
count = 0;
step = 1;
increment() {
this.count += this.step;
}
}
Equivalent to setting them in the constructor:
class Counter {
constructor() {
this.count = 0;
this.step = 1;
}
increment() {
this.count += this.step;
}
}
Field syntax is cleaner. Each instance gets its own count and step. Use the field form when the value is a fixed default; use the constructor form when the value depends on arguments.
Methods vs functions: this is the class instance #
Inside a class method, this refers to the instance the method was called on.
class User {
constructor(name) { this.name = name; }
greet() { return `Hi, ${this.name}`; }
}
const u = new User('Ada');
u.greet(); // 'Hi, Ada' — `this` is `u`
const grab = u.greet;
grab(); // TypeError — `this` is undefined (in strict mode)
The lost-this bug is the most common one with classes. Three fixes:
Arrow methods (class field syntax) — bound to the instance automatically:
class User { constructor(name) { this.name = name; } greet = () => `Hi, ${this.name}`; }Now
const grab = u.greet; grab()works. The trade-off: each instance has its owngreetfunction (not shared via prototype) — slightly more memory per instance, but simpler when methods are passed around..bind(this)in the constructor — older pattern with the same effect:constructor(name) { this.name = name; this.greet = this.greet.bind(this); }Call with
thisexplicitly —u.greet()orsomeCallback(() => u.greet()).
For methods that callers will pass around (event handlers, callbacks), arrow class fields are the modern default. For methods always called as instance.method(), plain method syntax is fine.
Static members #
Members that belong to the class, not to instances. Prefix with static.
class User {
static instances = 0;
static create(name) {
User.instances++;
return new User(name);
}
constructor(name) {
this.name = name;
}
}
User.create('Ada');
User.create('Grace');
console.log(User.instances); // 2
Access via the class name: User.create(...), User.instances. Static methods are factories, utilities, or class-level state.
Static blocks #
For complex static initialization, use a static {} block:
class Config {
static defaults;
static {
try {
this.defaults = JSON.parse(env.CONFIG);
} catch {
this.defaults = {};
}
}
}
Runs once when the class is defined. Has access to private fields too.
Inheritance with extends #
One class can extend another:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
speak() {
return `${this.name} barks`;
}
}
const d = new Dog('Rex');
d.speak(); // 'Rex barks'
A Dog IS-an Animal plus extras. The child can override methods.
super #
From inside a child class, super refers to the parent.
Two uses:
1. super(...) in the constructor #
If you write a constructor in the child, you must call super(...) before using this:
class Dog extends Animal {
constructor(name, breed) {
super(name); // calls Animal's constructor
this.breed = breed;
}
}
Without the super(name), accessing this throws.
If you don't write a constructor at all, the parent's is used automatically with all the args you passed.
2. super.methodName(...) to call the parent's method #
class Dog extends Animal {
speak() {
return super.speak() + ' (woof)'; // adds to parent behavior
}
}
Useful when you want to extend, not replace, parent behavior.
When to use inheritance — and when not to #
Inheritance is one of the most overused features in OOP. The signal that it's the right tool:
- The child IS-A more specific kind of the parent. Dog IS-A Animal. PNGFile IS-A File.
- The shared behavior is substantial — not just one or two methods.
- You're not going to need multiple inheritance later.
The signal that it's the wrong tool:
- You only inherit because you want to reuse a couple of methods. (Use composition or mixins — Lesson 4.5.)
- The relationship is HAS-A, not IS-A. A
CarHAS-AEngine. It is NOT an engine. Use composition. - You're using inheritance to avoid passing the same options. (Just pass them.)
We cover the composition-vs-inheritance trade-off in Lesson 4.5.
A worked example: extending a built-in #
Most built-in classes (Array, Map, Error, EventTarget) can be extended:
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.name = 'NotFoundError';
this.resource = resource;
}
}
try {
throw new NotFoundError('User 42');
} catch (e) {
console.log(e.name); // 'NotFoundError'
console.log(e.message); // 'User 42 not found'
console.log(e instanceof Error); // true
}
This is the cleanest way to define typed errors. We covered error handling patterns in the existing Module 5 Error Handling lesson.
instanceof and extends #
const d = new Dog('Rex');
d instanceof Dog; // true
d instanceof Animal; // true — extends chain
d instanceof Object; // true
instanceof walks the prototype chain. Useful for runtime type checks in error handlers, polymorphic dispatch, and library code.
Compared to plain object factories #
Classes aren't always the right shape. For data records with no behavior, plain objects are simpler:
// Class (with behavior)
class Point {
constructor(x, y) { this.x = x; this.y = y; }
distance() { return Math.sqrt(this.x ** 2 + this.y ** 2); }
}
// Plain object (just data)
const makePoint = (x, y) => ({ x, y });
const distance = (p) => Math.sqrt(p.x ** 2 + p.y ** 2);
For most modern JavaScript, a plain function returning an object is the right tool when there's no shared behavior. Classes earn their keep when you have methods, want instanceof checks, or genuinely need inheritance.
A practical rule: default to plain objects + functions. Reach for class when you need instance methods or instanceof.
A summary #
classis syntax over prototypes — not a new model.- Field syntax declares instance properties cleanly.
thisinside methods refers to the instance — but is easily lost when methods are passed around. Arrow class fields fix it.staticmembers belong to the class, not instances.extendssets up prototype-chain inheritance.supercalls the parent.- Inheritance is for IS-A relationships only. Composition usually beats inheritance in modern code.
What's next #
Lesson 4.4 covers private fields (the #name syntax) and static blocks — the encapsulation primitives that finally give classes proper private state. Lesson 4.5 covers mixins and composition — the alternative to deep inheritance hierarchies.
Try it yourself #
The lost-this bug is the most common surprise. Predict the result:
class Greeter {
constructor(name) { this.name = name; }
greet() { return 'Hi, ' + this.name; }
}
const g = new Greeter('Ada');
const fn = g.greet;
try { console.log(fn()); }
catch (e) { console.log(e.message); }js_sandboxOutput: Cannot read properties of undefined (reading 'name').Pulling
g.greet into fn detaches the method from its instance. When fn() runs, this is undefined (classes run in strict mode), so this.name throws.The fix: either define
greet as an arrow class field (greet = () => 'Hi, ' + this.name), or always call it via the instance (g.greet()).One unbound method, one cryptic error. Internalize this and a lot of class-related bugs become obvious in advance.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


