Skip to content

this, Prototypes, and Classes

The most misleading way to learn JavaScript classes is to begin with class syntax and assume the familiar appearance implies a familiar object model. JavaScript classes are useful and conventional, but methods still follow JavaScript's function-call rules, and inheritance still works through prototype delegation.

A single example exposes the central issue:

js
class Counter {
  count = 0;

  increment() {
    this.count += 1;
  }
}

const counter = new Counter();
counter.increment();

const increment = counter.increment;
increment(); // TypeError in strict mode

The method did not permanently remember counter. The expression used to call it determined its receiver.

Ordinary function calls determine this at the call site

For an ordinary function, ask how it is called.

Method call

In a property-reference call, the object before the dot becomes this:

js
const account = {
  balance: 50,

  describe() {
    return `Balance: ${this.balance}`;
  },
};

account.describe(); // "Balance: 50"

The function is stored in account.describe; calling through that property reference supplies account as the receiver.

The receiver is not necessarily the object where the function was originally defined:

js
const describe = account.describe;

const otherAccount = {
  balance: 90,
  describe,
};

otherAccount.describe(); // "Balance: 90"

Plain function call

Calling the extracted function has no receiver:

js
const describe = account.describe;
describe();

ECMAScript modules and class bodies are strict. In a strict plain function call, this is undefined, so reading this.balance throws.

Older non-strict scripts can substitute the global object, which is another reason not to reason from legacy examples.

Explicit receiver

call and apply invoke an ordinary function with an explicit this:

js
function transfer(amount, destination) {
  this.balance -= amount;
  destination.balance += amount;
}

transfer.call(account, 10, otherAccount);
transfer.apply(account, [10, otherAccount]);

The difference is how arguments are supplied. These APIs are useful for understanding invocation and for occasional generic function reuse, but application code should not be built from pervasive receiver manipulation.

bind creates a new function with a fixed receiver and optional leading arguments:

js
const boundDescribe = account.describe.bind(account);
boundDescribe(); // "Balance: 30"

Binding is common when an API stores a callback and later invokes it as a plain function:

js
button.addEventListener("click", counter.increment.bind(counter));

Remember that each bind call creates a new function. Removing a listener requires the same function reference, so store it when cleanup matters.

Arrow functions capture surrounding this

An arrow function does not establish its own this. It resolves this lexically from the surrounding scope:

js
class Counter {
  count = 0;

  start() {
    setInterval(() => {
      this.count += 1;
      console.log(this.count);
    }, 1000);
  }
}

The timer invokes the callback as a plain function, but the arrow uses the this from start, which was supplied by counter.start().

Changing the callback to an ordinary function would give it its own call-site-determined this:

js
setInterval(function () {
  this.count += 1; // this is not the Counter instance
}, 1000);

An arrow stored as an instance field can create an automatically receiver-stable callback:

js
class Counter {
  count = 0;

  increment = () => {
    this.count += 1;
  };
}

Each instance receives its own function object. A prototype method, by contrast, is shared. Arrow fields can be appropriate for callbacks in some UI patterns, but using them for every method costs per-instance allocation and changes inheritance behavior. Binding at the integration boundary or avoiding this may be clearer.

Avoid this when a parameter is the real dependency

This method gains little from a receiver:

js
const formatter = {
  prefix: "Transaction",

  format(transaction) {
    return `${this.prefix}: ${transaction.id}`;
  },
};

A function factory makes the dependency explicit and produces a stable callback:

js
function createFormatter(prefix) {
  return transaction => `${prefix}: ${transaction.id}`;
}

const formatTransaction = createFormatter("Transaction");

this is useful when a coherent object has identity and related operations. It is not a requirement for organizing every group of functions.

Objects delegate property lookup through prototypes

When a property is not found directly on an object, JavaScript follows its prototype:

js
const animal = {
  speak() {
    return `${this.name} makes a sound`;
  },
};

const dog = Object.create(animal);
dog.name = "Milo";

dog.speak(); // "Milo makes a sound"

dog does not own speak:

js
Object.hasOwn(dog, "speak"); // false
"speak" in dog; // true

The method is found on animal, but the call expression is dog.speak(), so this is dog.

Property lookup continues through a chain until a property is found or the prototype is null. Ordinary object literals inherit from Object.prototype, which supplies methods such as toString.

js
Object.getPrototypeOf({}) === Object.prototype; // true
Object.getPrototypeOf(Object.prototype) === null; // true

An own property shadows an inherited property of the same name:

js
dog.speak = function () {
  return `${this.name} barks`;
};

dog.speak(); // "Milo barks"

This delegation model is the foundation beneath class methods.

Constructor functions reveal what classes organize

Before class syntax, constructor functions were a common pattern:

js
function Counter(initial = 0) {
  this.count = initial;
}

Counter.prototype.increment = function () {
  this.count += 1;
};

const counter = new Counter(5);
counter.increment();

Calling with new roughly:

  1. Creates a new object whose prototype is Counter.prototype.
  2. Calls Counter with the new object as this.
  3. Returns the new object unless the constructor explicitly returns another object.

The instance owns count; the shared prototype owns increment:

js
Object.hasOwn(counter, "count"); // true
Object.hasOwn(counter, "increment"); // false
Object.getPrototypeOf(counter) === Counter.prototype; // true

This explains why putting methods inside a constructor creates a separate function for every instance, while prototype methods are shared.

Class syntax packages this pattern with clearer grammar and stricter semantics.

Classes define constructors and shared methods

The equivalent class is:

js
class Counter {
  constructor(initial = 0) {
    this.count = initial;
  }

  increment() {
    this.count += 1;
  }

  reset() {
    this.count = 0;
  }
}

Methods are installed on Counter.prototype. The constructor initializes each instance.

Public field syntax can declare and initialize instance fields:

js
class Counter {
  count = 0;
  label;

  constructor(label) {
    this.label = label;
  }
}

Field initializers run for each instance. They make instance shape visible near the top of the class, though codebases differ on whether fields initialized in the constructor need a separate declaration in plain JavaScript.

Static members belong to the class constructor itself:

js
class Counter {
  static fromSnapshot(snapshot) {
    return new Counter(snapshot.count);
  }

  constructor(count = 0) {
    this.count = count;
  }
}

const counter = Counter.fromSnapshot({ count: 4 });

Static factories can express named construction policies more clearly than an overloaded constructor.

Private fields are enforced by the language

A name beginning with # is a private element:

js
class Counter {
  #count = 0;

  increment() {
    this.#count += 1;
  }

  get count() {
    return this.#count;
  }
}

Outside code cannot access counter.#count; that is a syntax error. Private names are not string-keyed properties and cannot be reached through bracket notation.

Private fields are private to the declaring class body, not merely protected from ordinary callers. Subclasses cannot directly access a parent's private field. Provide protected-like behavior through methods or redesign the relationship.

This differs from naming a property _count, which is only a convention, and from TypeScript's private modifier, whose runtime enforcement depends on the emitted representation. TypeScript can also use JavaScript #private fields.

Getters and setters expose property syntax backed by methods:

js
class Temperature {
  #celsius;

  constructor(celsius) {
    this.#celsius = celsius;
  }

  get fahrenheit() {
    return this.#celsius * (9 / 5) + 32;
  }
}

const temperature = new Temperature(20);
temperature.fahrenheit; // 68

Getters should behave like property reads. Avoid hiding surprising network calls, expensive work, or mutations behind them.

Inheritance extends the prototype chain

extends connects a subclass prototype to its parent:

js
class Job {
  constructor(id) {
    this.id = id;
    this.status = "pending";
  }

  complete() {
    this.status = "completed";
  }
}

class RetriableJob extends Job {
  constructor(id, maxAttempts) {
    super(id);
    this.maxAttempts = maxAttempts;
    this.attempts = 0;
  }

  recordAttempt() {
    this.attempts += 1;
  }
}

A derived constructor must call super() before accessing this. Method lookup on an instance can proceed through RetriableJob.prototype, then Job.prototype, then Object.prototype.

Inside a method, super.method() calls the corresponding parent implementation while keeping the current receiver:

js
class LoggedJob extends Job {
  complete() {
    console.log(`Completing ${this.id}`);
    super.complete();
  }
}

Inheritance is appropriate when the subtype relationship is stable and substitutable. It becomes brittle when subclasses exist mainly to combine optional behaviors. Composition often keeps capabilities independent:

js
function createJob({ id, retryPolicy, logger }) {
  let status = "pending";

  return {
    get status() {
      return status;
    },

    async run(operation) {
      logger.info(`Running ${id}`);
      status = "running";

      const result = await retryPolicy.run(operation);
      status = result.ok ? "completed" : "failed";
      return result;
    },
  };
}

This example uses a closure factory and injected collaborators. A class could use the same composition. The choice is not “functional versus object-oriented”; it is about where identity and behavior live.

instanceof follows prototypes, not structural shape

instanceof usually checks whether a constructor's prototype appears in an object's prototype chain:

js
counter instanceof Counter; // true

It does not validate that an arbitrary object has equivalent fields:

js
({ count: 0 }) instanceof Counter; // false

Values crossing browser realms can have different built-in constructors even when they represent the same kind of object, and custom classes can influence instanceof behavior. Use it when runtime class identity is part of the contract, not as a universal object-shape validator.

For JSON data, class instances do not emerge automatically:

js
const parsed = JSON.parse('{"count": 3}');
parsed instanceof Counter; // false

Construct or validate domain values explicitly.

Class, factory, or plain object?

A plain object is often enough for data or a small set of stateless operations:

js
const formatter = {
  text(report) {
    return `Total: ${report.total}`;
  },

  json(report) {
    return JSON.stringify(report);
  },
};

A closure factory is strong when a small API owns private mutable state or needs stable callback functions:

js
function createSelection() {
  let selectedId = null;

  return {
    select(id) {
      selectedId = id;
    },

    getSelectedId() {
      return selectedId;
    },
  };
}

A class is strong when many instances share methods, runtime identity matters, construction needs a clear type, or an ecosystem expects class instances:

js
class LruCache {
  #entries = new Map();

  constructor(capacity) {
    this.capacity = capacity;
  }

  get(key) {
    // shared implementation using per-instance state
  }
}

Ask practical questions:

  • Does the value have meaningful identity?
  • Will there be many instances sharing behavior?
  • Is private mutable state central to the API?
  • Do methods need to be passed as callbacks?
  • Does inheritance clarify or complicate the model?
  • Will the value cross a JSON or worker boundary?
  • Which form makes invalid use hardest and testing easiest?

JavaScript supports all three styles naturally. Conventional codebases mix them.

The method-extraction rule is the one to remember

Most everyday this bugs reduce to one question: was the function called through the object that should be its receiver?

js
const method = object.method;
method(); // receiver lost

Possible responses are:

  • Keep the method call attached: object.method().
  • Bind once and store the result.
  • Wrap it: () => object.method().
  • Use an arrow field when instance-specific callback identity is appropriate.
  • Refactor the function to accept its dependency as a parameter or closure.

Do not cargo-cult binding every method. Understand the integration boundary that will call it.

Parts 1 and 2 have now established the runtime layers, execution environment, values, collections, functions, closures, and object model. Part 3 builds directly on those foundations by examining how modules organize a program, how errors cross boundaries, and how promises and the event loop shape asynchronous control flow.

Further reference