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
Names You Can Look Up from task
Searched by task.name
  • Searched in order: task itself → Task.prototype → Object.prototype
task itself
  • title, priority — listed as keys and included in JSON
Task.prototype (shared)
  • complete(), get status() — found when searched
Private fields task.name can't reach
  • #done — stored on task, but readable only as this.#done inside the class
Task itself (static)
  • count, fromJSON() — outside the search range
  • task.fromJSON is undefined
# names and static members are outside the range task searches. task.fromJSON isn't found, so call it on the class name.

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.

SyntaxWhere it livesRead from task
this.title in the constructortask itselfIn keys and JSON
Class field prioritytask itselfIn keys and JSON
Private field #donetask itself (class only)Not in keys or JSON
Method complete()Task.prototypeCallable, not in keys
get / set statusTask.prototypeRead without ()
static count / fromJSON()Task itselfundefined

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
The Order of Levels Searched from weekly
weekly(the instance)Has titleand weekdayRecurringTask.prototypeFinds describe← weekly.describe()Task.prototypeFinds complete← super.describe()Object.prototypeFinds toString← ${weekly}
describe is found at the second level and complete at the third. The parent's describe runs only when you add super.

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')
How this Is Set for a Function You Pull Out
const runMethod= task.completeRegular method:set where calledrunMethod(): noobject left of dotthis is undefinedTypeErrorconst runArrow= task.onCompleteArrow functionfield: set at newEven in runArrow()this stays taskDone: Sendthe invoice
this in runMethod() is undefined. If you write it the way the bottom row does, a function you pull out and pass around keeps task.

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 calledWhen this is setWhen pulled out and passed
Regular methodLeft of the dot on the callLoses task, TypeError
Return value of bindWhen bind is calledStill runs as task
Arrow function fieldAt newStill runs as task
Wrapped in () => task.complete()When the wrapper calls task.complete()Still runs as task
Method added with Object.assignLeft of the dot on the callLoses 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
Where You Write a Feature Decides What It Reaches
first and secondmade from TaskObject.assign(first, piece)Task.prototype.label = functionextendsAssignable(Task)Goes into first'sown keysAdded once toTask.prototypeAdds a level aboveTeamTaskfirst only; secondgets undefinedBoth existingtasks can call itOnly new TeamTask(not first/second)
Written on first itself, it reaches just that one task; on Task.prototype, it reaches both existing tasks. A mixin reaches only instances of the new class.

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')
Three Points Where an Error Stops Your Code
① While thecode loadsSyntaxErrorNot a line runs② new runsthe constructorReferenceErrorLines abovenew run③ A methodis calledTypeErrorLines abovethe call run
The higher the row, the earlier it stops; with ①, even the check message doesn't appear. If nothing is printed at all, suspect a SyntaxError at load time.

② 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 messageWhat causes itHow to fix it
SyntaxError: Private fieldtask.#done outside the classRead it via a getter
SyntaxError: … export namedDefault export read as { Cart }Remove the braces
ReferenceError: Must call superthis before super()Call super() first
TypeError: … without 'new'class called without newAdd new
TypeError: Cannot read propertiesPulled-out method called as run()bind it or wrap it in an arrow
RangeError: Maximum call stacksetter assigns to its own name / super. left outUse another name / add super.
QUIZ

Knowledge Check

Answer each question one by one.

Q1RecurringTask doesn't define complete. At which level is the definition for weekly.complete() found?

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?