Q1RecurringTask doesn't define complete. At which level is the definition for weekly.complete() found?
OOP Recap — Class Syntax and this at a Glance
Quick-reference tables for this category's class syntax: where members live, the lookup order for names, how this is set, where to add features, and when errors stop your code.
In task management code, static and # members, subclasses made with extends, and methods pulled out of objects all end up in one file. If you haven't sorted out how they differ, you can't trace where an undefined or a TypeError comes from.
This article reviews this category's syntax in quick-reference tables, focusing on class syntax and this.
Reading Members by Where They Live — Class Syntax at a Glance
Say you're reading a Task class someone else wrote. Lines with static, #, and get sit side by side in the same braces, and reading them one line at a time won't tell you why task.fromJSON(text) throws a TypeError or why the completion state doesn't show up in JSON.stringify(task).
How you write something in a class body decides where it lives: on the instance itself, on Task.prototype (where methods shared by all instances go), or on the class itself. When a name is read, JavaScript looks at the instance itself, then Task.prototype, then Object.prototype.
class Task {
static count = 0; // Only Task itself has one
priority = "normal"; // Added to each task on every new
#done = false; // Stored on task, but not readable outside the class
constructor(title) {
this.title = title;
Task.count += 1;
}
complete() { this.#done = true; } // Placed on Task.prototype, shared by all instances
get status() { return this.#done ? "done" : "not started"; }
static fromJSON(text) { return new Task(JSON.parse(text).title); }
}
// Check which names you can and can't read from an instance
const task = Task.fromJSON('{"title":"Send the quote"}');
task.complete();
console.log(task.status, Task.count); // done 1
console.log(Object.keys(task).join(", ")); // priority, title
console.log(JSON.stringify(task)); // {"priority":"normal","title":"Send the quote"}
console.log(task.hasOwnProperty("complete"), typeof task.fromJSON); // false undefined
- Searched in order: task itself → Task.prototype → Object.prototype
title,priority— listed as keys and included in JSON
complete(),get status()— found when searched
#done— stored on task, but readable only asthis.#doneinside the class
count,fromJSON()— outside the search rangetask.fromJSONisundefined
status lives on Task.prototype, and #done is on task but isn't listed as a key, so neither shows up in Object.keys or JSON.stringify. To include them in JSON, return the values you want from toJSON. The table below lists this category's syntax by where each member lives.
| Syntax | Where it lives | Read from task |
|---|---|---|
| this.title in the constructor | task itself | In keys and JSON |
| Class field priority | task itself | In keys and JSON |
| Private field #done | task itself (class only) | Not in keys or JSON |
| Method complete() | Task.prototype | Callable, not in keys |
| get / set status | Task.prototype | Read without () |
| static count / fromJSON() | Task itself | undefined |
Tracing Which Definition Runs — extends and super
Take a weekly recurring task made with class RecurringTask extends Task. Looking at the line weekly.describe(), you can't tell whether the subclass's describe or the parent's runs, or why you can call complete when the subclass doesn't define it.
A name is looked up along the prototype chain, starting from the object itself, and the first definition found is the one that runs. A subclass's override is found before the parent's, and super.describe() starts searching from the parent. Methods written separately for each kind with polymorphism are found in this same order.
class Task {
constructor(title) { this.title = title; }
describe() { return `Task: ${this.title}`; }
complete() { return `Done: ${this.title}`; }
}
// Subclass: calls the parent constructor with super() and overrides only describe
class RecurringTask extends Task {
constructor(title, weekday) { super(title); this.weekday = weekday; }
describe() { return `${super.describe()} (every ${this.weekday})`; }
}
// Call them to see which definition runs
const weekly = new RecurringTask("Submit the weekly report", "Friday");
console.log(weekly.describe()); // Task: Submit the weekly report (every Friday)
console.log(weekly.complete()); // Done: Submit the weekly report
console.log(`${weekly}`); // [object Object]
console.log(weekly instanceof Task, typeof weekly.archive); // true undefined
A name that isn't on any level, like archive, is undefined, and calling it throws a TypeError. ${weekly} gives [object Object] because the default toString on the last level runs.
Telling this Apart by How a Function Is Called — this at a Glance
Now suppose you store a task's completion method in a variable to call later, or pass it to setTimeout. The method that works as task.complete() can end up with a this that isn't task, depending on how you pass it, and then it throws a TypeError or this.title comes out as undefined.
When the value of this is set depends on how the function is written. For a regular method in a class, it's set by what's left of the dot on the line that calls it; for an arrow function field, it's set when new creates the instance. What bind returns is a separate function with this fixed to the value you passed to bind.
class Task {
onComplete = () => `Done: ${this.title}`; // this is the task from new
constructor(title) { this.title = title; }
complete() { return `Done: ${this.title}`; } // this is set by the calling line
}
const task = new Task("Send the invoice");
// Call it with task left of the dot
console.log(task.complete()); // Done: Send the invoice
// Store it in a variable, then call it
const runMethod = task.complete;
const runArrow = task.onComplete;
const runBound = task.complete.bind(task);
console.log(runArrow()); // Done: Send the invoice
console.log(runBound()); // Done: Send the invoice
// runMethod(); // TypeError: Cannot read properties of undefined (reading 'title')
runBound in the code also still runs as task after being pulled out. With setTimeout(task.complete, 100), the browser sets this to window, so wrap it in an arrow function. The table below shows when this is set for each way of calling.
| How it's called | When this is set | When pulled out and passed |
|---|---|---|
| Regular method | Left of the dot on the call | Loses task, TypeError |
| Return value of bind | When bind is called | Still runs as task |
| Arrow function field | At new | Still runs as task |
| Wrapped in () => task.complete() | When the wrapper calls task.complete() | Still runs as task |
| Method added with Object.assign | Left of the dot on the call | Loses its target object |
A Subclass Method with the Same Name Never Runs
Even if you write class RecurringTask extends Task { onComplete() { … } }, the arrow function field from the parent Task is put on weekly itself and found first, so weekly.onComplete() runs the parent's function. If subclasses need to override it, define it as a method in the parent as well.
Choosing Where to Add a Feature — Inheritance, prototype, or Composition
You're adding features to a task manager that's already in use: pinning a single task, showing a label on every existing task, or adding assignees only to team tasks. If you write the feature in the wrong place, a task you thought had it throws a TypeError.
The ways of adding features covered in this category differ in where the feature is written: the instance itself, Task.prototype, or a new level linked with extends. Once you know where it's written, you also know which instances can find that name along their chain.
class Task {
constructor(title) { this.title = title; }
}
const first = new Task("Send the quote"); // Create 2 tasks first
const second = new Task("Write up the minutes");
// Copy into just one task
Object.assign(first, { pin() { return `Pinned: ${this.title}`; } });
// Assign to Task.prototype
Task.prototype.label = function () { return `Task: ${this.title}`; };
// Create a class with an extra level added by a mixin
const Assignable = (Base) => class extends Base {
assign(name) { return `Assigned to ${name}: ${this.title}`; }
};
class TeamTask extends Assignable(Task) {}
console.log(typeof second.pin, second.label()); // undefined Task: Write up the minutes
console.log(typeof first.assign); // undefined
console.log(new TeamTask("Submit the weekly report").assign("Alice")); // Assigned to Alice: Submit the weekly report
label reaches second, which already existed, because second searches Task.prototype along its chain. Assigning to the prototype is for classes whose body you can't rewrite. A method written in a subclass, like a mixin, reaches only instances created from that subclass.
Use typeof to Check Features Added to Single Objects
Even though Object.assign copied pin into first only, instanceof Task is true for both first and second. Since the class can't tell them apart, check that the method exists with typeof task.pin === "function" before calling it.
Finding the Cause by When It Stops — Errors at a Glance
When class code throws an error, the line where it stopped and the line you need to fix can be far apart. A mistake in a subclass's constructor throws from a line inside it when new runs, and with a # name written outside the class, even the console.log output from lines above it doesn't appear.
Errors fall into three groups by when they stop your code: while the code is being loaded, when new runs the constructor, or when a method is called. With a SyntaxError at load time, not a single line runs; with the other two, the lines before the error run first and then it stops.
class Task {
#done = false;
constructor(title) { this.title = title; }
complete() { return `Done: ${this.title}`; }
}
class RecurringTask extends Task {
constructor(title, weekday) {
this.weekday = weekday; // Used this before super()
super(title);
}
}
console.log("Starting the check"); // Printed with ② and ③, but not if line ① is present
// ① At load time: console.log(new Task("Send the invoice").#done);
// SyntaxError: Private field '#done' must be declared in an enclosing class
// ② At new: new RecurringTask("Submit the weekly report", "Friday");
// ReferenceError: Must call super constructor in derived class before accessing 'this' …
// ③ At call time: const run = new Task("Send the invoice").complete; run();
// TypeError: Cannot read properties of undefined (reading 'title')
② and ③ print everything up to that point before stopping, so you can look for the cause just after the last output. The table below lists the errors that came up in this category, ordered from the earliest stopping point.
| Error message | What causes it | How to fix it |
|---|---|---|
| SyntaxError: Private field | task.#done outside the class | Read it via a getter |
| SyntaxError: … export named | Default export read as { Cart } | Remove the braces |
| ReferenceError: Must call super | this before super() | Call super() first |
| TypeError: … without 'new' | class called without new | Add new |
| TypeError: Cannot read properties | Pulled-out method called as run() | bind it or wrap it in an arrow |
| RangeError: Maximum call stack | setter assigns to its own name / super. left out | Use another name / add super. |
Knowledge Check
Answer each question one by one.
Q2If you call an arrow function field as const run = task.onComplete; run();, what is this?
Q3If a new after a console.log throws a ReferenceError, what happens to that output?