
Post #6’s TaskModule works well for exactly one task list. What happens when you need several independent ones — a personal list, a work project list, each tracking its own tasks with the same shared behavior? Closures alone do not naturally scale to “many independent instances sharing behavior” the way a proper class system does — which is precisely the gap classes fill.
JavaScript’s class system, however, is not what it appears to be at first glance. class syntax, added in ES6, is syntax sugar over a mechanism that existed in the language from the very beginning: prototypes. Every JavaScript object has a hidden link to another object — its prototype — from which it can borrow properties and methods. Understanding this underlying mechanism, rather than treating class as a self-contained feature imported wholesale from other languages, is what makes JavaScript’s occasional class-related surprises make sense rather than feel arbitrary.
The Mental Model: Prototypes First, Classes as Sugar on Top
Before class existed, JavaScript developers created reusable object “types” using constructor functions combined with the prototype — every function has a prototype property, and objects created from that function via new are linked to it, able to use any methods defined there.
function Task(description, priority) {
this.description = description;
this.priority = priority;
this.completed = false;
}
Task.prototype.complete = function () {
this.completed = true;
console.log(`Completed: ${this.description}`);
};
const task1 = new Task("Learn JS", "high");
const task2 = new Task("Build a project", "medium");
task1.complete(); // "Completed: Learn JS"
What new Actually Does
new Task(...) performs four steps, in order: it creates a brand-new, empty object; it links that new object’s internal prototype to Task.prototype; it calls Task with this bound to the new object (exactly the call-site-determined this behavior covered in Post #3); and it returns the new object automatically (unless the constructor explicitly returns a different object itself, an edge case rarely used deliberately).
console.log(task1.complete === task2.complete); // true — both share the exact SAME function
console.log(Object.getPrototypeOf(task1) === Task.prototype); // true
This is the crucial insight: complete is defined once, on Task.prototype, and every instance shares that same function via the prototype link — it is not duplicated per instance the way description and priority are (those live directly on each individual object, since they are set inside the constructor with this.description = ...).
ES6 Classes: The Same Mechanism, Cleaner Syntax
class Task {
constructor(description, priority) {
this.description = description;
this.priority = priority;
this.completed = false;
}
complete() {
this.completed = true;
console.log(`Completed: ${this.description}`);
}
}
const task1 = new Task("Learn JS", "high");
This looks meaningfully different from the constructor-function version above — and produces the exact same underlying result:
console.log(typeof Task); // "function" — a class IS a function underneath
console.log(task1.complete === Task.prototype.complete); // true — methods still live on the prototype
class did not introduce a new object model — it introduced cleaner, more familiar syntax over the identical prototype-based mechanism that already existed. This is worth internalizing precisely, because it explains several class behaviors (covered below) that would otherwise seem arbitrary if you assumed JavaScript classes worked exactly like classes in other languages with a genuinely separate class-based object system underneath.
Inheritance with extends and super
class TaskList {
constructor(name) {
this.name = name;
this.tasks = [];
}
addTask(description, priority = "medium") {
this.tasks.push(new Task(description, priority));
console.log(`Added to ${this.name}: "${description}"`);
}
listTasks() {
console.log(`\n=== ${this.name} ===`);
this.tasks.forEach((task, index) => {
const status = task.completed ? "✓" : " ";
console.log(`[${status}] ${index + 1}. ${task.description} (${task.priority})`);
});
}
get completionRate() {
if (this.tasks.length === 0) return 0;
const completed = this.tasks.filter((t) => t.completed).length;
return Math.round((completed / this.tasks.length) * 100);
}
}
class ProjectTaskList extends TaskList {
constructor(name, deadline) {
super(name); // calls TaskList's constructor first
this.deadline = deadline;
}
listTasks() {
super.listTasks(); // reuse the parent's listing logic
console.log(`Deadline: ${this.deadline} | Completion: ${this.completionRate}%`);
}
}
const personal = new TaskList("Personal");
personal.addTask("Learn JavaScript classes");
personal.listTasks();
const project = new ProjectTaskList("Q3 Launch", "2026-09-30");
project.addTask("Finalize design", "high");
project.addTask("Write documentation", "medium");
project.tasks[0].complete();
project.listTasks();
extends TaskList declares ProjectTaskList as a subclass — it inherits every method TaskList defines. super(name) calls the parent class’s constructor, exactly analogous to the identical concept in this blog’s Python series. super.listTasks(), inside the overridden listTasks method, calls the parent’s version of that method before adding the subclass-specific deadline and completion-rate output — reusing rather than duplicating the parent’s formatting logic.
The get Keyword: Computed Properties That Read Like Data
completionRate in TaskList is a getter — declared with get, it is accessed like a plain property (project.completionRate, no parentheses) while actually running a function behind the scenes to compute its value fresh every time it is read. This is genuinely useful for values that are always derived from other state rather than stored directly — accessing project.completionRate reads naturally as data, even though it is computed on demand from this.tasks every single time.
Private Fields: Genuine, Language-Enforced Privacy
Post #6’s Module Pattern achieved data privacy indirectly, through closures. ES2022 added a directly built-in mechanism for the same goal, enforced by the JavaScript engine itself:
class BankAccount {
#balance; // private field declaration — the # is part of the syntax itself
constructor(initialBalance) {
this.#balance = initialBalance;
}
deposit(amount) {
this.#balance += amount;
}
withdraw(amount) {
if (amount > this.#balance) {
console.log("Insufficient funds");
return;
}
this.#balance -= amount;
}
get balance() {
return this.#balance;
}
}
const account = new BankAccount(100);
account.deposit(50);
console.log(account.balance); // 150 — accessed via the public getter
console.log(account.#balance); // SyntaxError — genuinely inaccessible from outside the class
Unlike Post #6’s closure-based privacy — which works, but relies on carefully never exposing the private data directly — #balance is enforced by the JavaScript engine itself. Attempting to access account.#balance from outside the class is not merely bad practice; it is a SyntaxError, caught before the code even runs. This is the modern, direct tool for the exact same encapsulation goal Post #6’s Module Pattern achieved indirectly.
Static Methods and Properties
class Task {
static defaultPriority = "medium";
constructor(description, priority = Task.defaultPriority) {
this.description = description;
this.priority = priority;
this.completed = false;
}
static createUrgent(description) {
return new Task(description, "high");
}
}
const urgentTask = Task.createUrgent("Fix critical production bug");
console.log(urgentTask.priority); // "high"
static members belong to the class itself, not to individual instances — Task.createUrgent(...) is called directly on Task, never on a specific task instance. This is the standard pattern for factory-style helper methods (an alternate way to construct instances with pre-configured settings) and shared constants relevant to the class as a whole rather than to any single instance.
The this Problem Returns, Identically, in Class Methods
Post #3 covered the this-losing-context bug in depth for plain object methods. Class methods have the exact same issue, because — as covered throughout this post — a class method is still, underneath, just a function attached via the prototype:
class Task {
constructor(description) {
this.description = description;
this.completed = false;
}
complete() {
this.completed = true; // relies on 'this' being the actual instance
console.log(`${this.description} completed`);
}
}
const task = new Task("Learn JS");
const completeFn = task.complete;
completeFn(); // TypeError — the exact same bug as Post #3, now in a class context
setTimeout(task.complete, 1000); // same bug
The Modern Fix: Arrow Function Class Fields
class Task {
description;
completed = false;
constructor(description) {
this.description = description;
}
complete = () => {
this.completed = true;
console.log(`${this.description} completed`);
};
}
const task = new Task("Learn JS");
const completeFn = task.complete;
completeFn(); // "Learn JS completed" — works correctly, even called standalone!
Declaring complete as an arrow function class field (rather than a regular method) means each instance gets its own copy of complete, defined as an arrow function — and because arrow functions have no own this (covered fully in Post #3), it permanently uses the this from where it was defined: inside the constructor’s scope, where this is genuinely the instance being created. This trades away the prototype-sharing memory efficiency (each instance now has its own copy of the method, rather than sharing one via the prototype) for guaranteed, correct this binding regardless of how the method is later called or passed around — a tradeoff worth making specifically for methods you know will be passed as callbacks (event handlers, setTimeout, and similar), and unnecessary for methods always called directly on the instance (task.complete(), never extracted).
instanceof: Checking an Object’s Prototype Chain
console.log(task instanceof Task); // true
console.log(project instanceof TaskList); // true — ProjectTaskList extends TaskList
console.log(project instanceof ProjectTaskList); // true
instanceof checks whether an object’s prototype chain includes the given class’s prototype — directly analogous to isinstance() in this blog’s Python series, including correctly recognizing inherited relationships (a ProjectTaskList instance genuinely is a TaskList, exactly the “is-a” relationship this blog’s Python content associates with legitimate inheritance use).
Real-World Use Cases
Modeling multiple independent instances of the same kind of thing: Exactly what TaskList and ProjectTaskList demonstrate — multiple task lists, each with independent data (tasks, name) but shared behavior (addTask, listTasks).
Building reusable, extensible components: extends and super are the standard pattern for building a specialized version of a general-purpose class, adding or overriding specific behavior without duplicating the shared logic — used extensively in UI frameworks, custom error types (Post #8), and library design generally.
Enforcing genuine data privacy in a library or shared module: Private fields (#field) are the direct, modern tool anywhere Post #6’s closure-based Module Pattern would previously have been the only option.
Factory methods for common configurations: static factory methods like Task.createUrgent(...) provide convenient, named ways to construct commonly-needed variations of a class without cluttering the main constructor with excessive conditional logic.
Common Mistakes and Gotchas
⚠️ Mistake 1: Forgetting new (mitigated, but worth understanding)
const task = Task("Learn JS"); // TypeError: Class constructor Task cannot be invoked without 'new'
Unlike old constructor functions (where forgetting new silently misbehaves — this ends up referring to something other than a new instance, often the global object), ES6 classes throw an immediate, clear error if called without new. This is a genuine safety improvement, not merely a stylistic change.
⚠️ Mistake 2: Passing a class method as a callback without accounting for this
Covered at length above — this is the exact Post #3 bug, reappearing identically in a class context. Use arrow function class fields for methods that will be passed around as callbacks; use regular methods (which share memory via the prototype) for methods always called directly on the instance.
⚠️ Mistake 3: Confusing static members with instance members
class Task {
static count = 0;
constructor() {
Task.count++; // access via the CLASS, not 'this', for static members
}
}
Static members belong to the class itself and are accessed via the class name (or, inside a static method, via this referring to the class) — never via an instance, and never accidentally through a regular (non-static) method expecting this to be the instance.
⚠️ Mistake 4: Assuming private fields (#field) work like a naming convention rather than genuine enforcement
Some older JavaScript conventions used a leading underscore (_balance) to signal “please don’t touch this from outside,” without any actual enforcement — external code could still access _balance directly, nothing stopped it. #balance is fundamentally different: it is a hard SyntaxError to access from outside the class, not a polite convention.
⚠️ Mistake 5: Overusing inheritance where composition, from this blog’s earlier design patterns coverage of other languages, would fit better
The “is-a versus has-a” test covered elsewhere on this blog applies identically in JavaScript — ProjectTaskList extends TaskList is appropriate because a project task list genuinely is a kind of task list with extra behavior; forcing an inheritance relationship where the connection is really “has-a” produces the same rigidity problems in JavaScript as in any other class-based language.
Performance Note
Regular class methods (defined normally, not as arrow function class fields) are shared via the prototype — one function in memory, referenced by every instance — which is more memory-efficient than arrow function class fields, where each instance gets its own separate copy of the function. For a handful of instances, this difference is irrelevant; for an application creating thousands or millions of instances of the same class, the memory difference between prototype-shared methods and per-instance arrow function fields becomes genuinely measurable. The practical guidance: use regular methods by default, and reach for arrow function class fields specifically for the methods that actually need guaranteed this binding as callbacks, rather than converting every method to an arrow function preemptively.
Quick Reference
// Constructor function + prototype (the pre-ES6 mechanism, still underneath class)
function Task(description) {
this.description = description;
}
Task.prototype.complete = function () { ... };
// ES6 class — same mechanism, cleaner syntax
class Task {
constructor(description) {
this.description = description;
}
complete() { ... } // shared via prototype
get someComputedValue() { ... } // accessed like a property, computed on read
static createSomething() { ... } // called on the class itself, not an instance
#privateField; // genuinely inaccessible from outside
boundMethod = () => { ... }; // per-instance, but 'this' is always correct
}
// Inheritance
class Subclass extends Task {
constructor(description, extra) {
super(description); // call parent constructor
this.extra = extra;
}
complete() {
super.complete(); // call parent's version of this method
// additional subclass-specific behavior
}
}
// Checking relationships
instance instanceof Task;
Exercises
Exercise 1 — Direct application
Write a Task class exactly as shown in this post, then write a constructor function + prototype version achieving identical behavior — confirming for yourself that both produce functionally equivalent objects.
Exercise 2 — Slight variation
Add a PersonalTaskList subclass extending TaskList, adding a category field (e.g., “Health”, “Learning”) set in its constructor via super, and overriding listTasks to include the category in its header, using super.listTasks() to reuse the base formatting.
Exercise 3 — Real-world combination
Convert BankAccount from this post so that its deposit method is passed to setTimeout to run after a delay — confirm it fails with the this-losing-context error, then fix it using an arrow function class field, confirming it now works correctly even when extracted and called standalone.
Exercise 4 — Open-ended challenge
Add a static factory method to TaskList called fromArray(name, descriptions) that accepts an array of description strings and returns a fully populated TaskList instance with one task created per description — using map from Post #4 internally rather than a manual loop.
FAQ
Q: Is JavaScript’s class system “fake” since it’s built on prototypes?
A: Not fake — genuinely functional, and the prototype mechanism underneath is a real, first-class part of the language, not a hidden implementation detail you need to work around. Understanding it deeply, as this post covers, explains real behaviors (the new-keyword requirement, method sharing via the prototype, the this-binding issue reappearing in classes) that would otherwise seem like arbitrary, disconnected rules.
Q: Should I always use arrow function class fields to avoid this-binding issues entirely?
A: Not by default — regular methods sharing memory via the prototype are more efficient at scale, and are entirely correct for methods always called directly on the instance. Reserve arrow function class fields specifically for methods you know will be extracted or passed as callbacks, exactly as complete was in this post’s fix.
Q: What’s the actual difference between a private field (#field) and a regular property with an underscore prefix (_field)?
A: The underscore version is purely a naming convention — a polite signal to other developers, with zero enforcement; external code can still access _field directly without any error. #field is enforced by the language itself — accessing it from outside the class is a SyntaxError, not merely bad practice.
Q: Can a class extend more than one other class, the way some languages support multiple inheritance?
A: No — JavaScript classes support single inheritance only, exactly one extends clause per class. For combining independent behaviors from multiple sources, composition (covered in this blog’s broader design patterns coverage) or a pattern called “mixins” (combining behavior via functions rather than class inheritance) are the standard JavaScript approaches instead.
Summary and Next Steps
You now understand that JavaScript’s class syntax is sugar over the prototype mechanism that has existed in the language since its creation — not a separate, bolted-on feature — and can use inheritance with extends/super, getters for computed properties, genuine private fields, static members, and the arrow-function-class-field fix for the exact same this-binding bug from Post #3, now correctly diagnosed in a class context.
Your next step: Complete Exercise 3 — deliberately reproducing the this-losing-context bug in BankAccount and fixing it with an arrow function class field — since seeing the identical Post #3 bug reappear in a completely different context (classes instead of plain objects) is the clearest possible confirmation that this really is determined by call-site, universally, regardless of which syntax created the function in the first place.
Code tested with Node.js 22 LTS. Last updated: July 2026.



