Why JavaScript Classes Fool Developers Who Already Know Classes

JavaScript classes look familiar because they use class, constructor, extends, and method syntax found in other languages. The similarity is useful only until it hides the language’s actual model. JavaScript classes create objects through constructor functions and prototype relationships; they do not turn every method into a per-instance copy or remove the rules of this, property lookup, and closures.

The practical question is not “are JavaScript classes real?” They are real language constructs with defined behavior. The better question is: which members belong to an instance, which belong to the class, and which behavior comes from the prototype chain? Once those boundaries are visible, most class surprises become ordinary debugging problems.

The first mismatch: the class is not the instance

Consider this class:

class User {
  constructor(name) {
    this.name = name;
  }

  greet() {
    return `Hello, ${this.name}`;
  }
}

const first = new User('Maya');
const second = new User('Noah');

User is the class definition. first and second are separate instances. Each instance has its own name property, but the greet() method is normally shared through User.prototype rather than copied as a new function onto every object.

console.log(first.name);                 // Maya
console.log(first.greet());               // Hello, Maya
console.log(first.greet === second.greet); // true

That final result surprises developers who imagine that every method is duplicated inside every instance. The method is available through the prototype lookup process. The MDN class reference describes classes as a special kind of function syntax while also documenting their prototype behavior.

A class body runs in strict mode

Code inside a class body executes in strict mode. This affects assignments, this, duplicate definitions, and other language rules. You should not rely on sloppy-mode behavior just because older JavaScript code sometimes allowed it.

A class method is also not automatically bound to its instance:

class Counter {
  count = 0;

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

const counter = new Counter();
const update = counter.increment;
// update(); // TypeError: this is undefined in strict mode

Calling counter.increment() supplies counter as this. Extracting the method into update removes that receiver. Use an arrow field, bind, or a wrapper when a callback must preserve the instance:

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

This is not a class-specific mystery. It is the normal JavaScript rule that method calls and detached function calls have different receivers.

The constructor initializes an instance; it does not create the class

The constructor method runs when an object is created with new. It is the place to validate inputs, assign instance state, and establish invariants.

class Temperature {
  constructor(celsius) {
    if (!Number.isFinite(celsius)) {
      throw new TypeError('Temperature must be a finite number');
    }

    this.celsius = celsius;
  }

  toFahrenheit() {
    return this.celsius * 9 / 5 + 32;
  }
}

The constructor is not called when you invoke a normal method, and it is not a general setup hook for the entire application. If the object can be constructed in an invalid state, later methods inherit that problem. Validate at the boundary instead of scattering assumptions across every method.

If a subclass defines no constructor, JavaScript supplies a default one that forwards arguments to the parent. If it defines a constructor, it must call super() before using this in a derived class.

extends connects prototype chains, not just copied methods

Inheritance creates a relationship between constructors and prototypes:

class Animal {
  speak() {
    return 'some sound';
  }
}

class Dog extends Animal {
  speak() {
    return 'bark';
  }

  fetch() {
    return 'toy';
  }
}

const dog = new Dog();
console.log(dog.speak()); // bark
console.log(dog.fetch()); // toy

When dog.speak() is evaluated, JavaScript first looks on dog, then on Dog.prototype, and then along the prototype chain toward Animal.prototype. The child method overrides the parent method for that lookup path.

The MDN guide to inheritance and the prototype chain explains that JavaScript objects inherit through internal prototype links. The class syntax does not remove that mechanism; it gives developers a more familiar way to define it.

Use super.speak() when the child needs to extend rather than replace the parent behavior:

class LoggedDog extends Dog {
  speak() {
    const sound = super.speak();
    console.log('sound requested');
    return sound;
  }
}

Deep inheritance chains can be difficult to reason about. Prefer a short hierarchy with a clear substitution rule. If a subclass needs to know too much about the parent’s internals, composition may be easier to test and change.

Instance members and static members answer different questions

An instance method answers a question about one object:

class User {
  constructor(name) {
    this.name = name;
  }

  displayName() {
    return this.name;
  }
}

Call it on an instance: user.displayName().

A static method answers a question about the class itself or provides a factory/utility related to that class. It is also a useful place to make the return type explicit when the operation creates a new instance from external data. Keep that factory small enough that callers can understand which validation and defaults happen before construction:

class User {
  static fromRecord(record) {
    if (!record || typeof record.name !== 'string') {
      throw new TypeError('Record needs a name');
    }

    return new User(record.name.trim());
  }
}

That boundary prevents every caller from duplicating the same construction assumptions:

class User {
  constructor(name) {
    this.name = name;
  }

  static anonymous() {
    return new User('Anonymous');
  }
}

const user = User.anonymous();

User.anonymous() works. user.anonymous() does not, because the method is on the constructor, not on User.prototype. The MDN static reference covers static methods, fields, accessors, and initialization blocks.

A common mistake is to make every helper static simply because it does not currently read instance state. The choice should communicate ownership. If the operation describes one object, keep it as an instance method. If it constructs, validates, or compares the class’s values without needing one instance, static can be appropriate.

Public fields are initialized per instance

JavaScript supports public instance fields:

class SearchRequest {
  status = 'idle';
  retries = 0;

  start() {
    this.status = 'running';
  }
}

Each new SearchRequest() receives its own fields. If a field contains an object or array, each instance should usually create its own value rather than sharing a mutable object accidentally.

class BadCart {
  items = sharedItems;
}

A module-level sharedItems array would be shared by every instance. Prefer items = [] when each cart owns its collection.

Private fields are enforced by the language

A field beginning with # is private to the class that declares it:

class Account {
  #balance = 0;

  deposit(amount) {
    if (amount <= 0) throw new RangeError('Amount must be positive');
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

account.#balance is a syntax error outside the class. This differs from a naming convention such as _balance, which is still publicly accessible. MDN private elements documents private fields, methods, accessors, and brand checks.

Private fields also belong to the declaring class. A subclass does not automatically access a parent’s private field by spelling the same name. Expose an intentional protected-like method or redesign the boundary instead of reaching into hidden state.

Private state is useful when the class must protect invariants, but it can make testing and serialization less direct. Do not use it merely because it looks advanced. Use it when outside code should not be able to put the object into an invalid state.

Classes, factories, and composition are different tools

A class is not the only way to create objects. A factory function can keep construction details private and return a plain object:

function createUser(name) {
  return {
    name,
    greet() {
      return `Hello, ${name}`;
    }
  };
}

Use a class when instances share a clear protocol, construction has a meaningful identity, or inheritance and private state genuinely help. Use a factory when you want simple data, closure-based privacy, or composition without a prototype hierarchy. Neither style is automatically more professional.

Composition can also avoid inheritance:

function withLogging(service) {
  return {
    request(...args) {
      console.log('request started');
      return service.request(...args);
    }
  };
}

A composed object delegates behavior instead of becoming a specialized subclass. This can reduce the number of assumptions a child class makes about a parent. The design question is whether the object “is a” specialized version or merely “uses” another capability.

Static initialization and class-level configuration

Modern classes can contain static fields and static initialization blocks. Static code runs in the context of the class, not an instance:

class FeatureFlags {
  static defaults = { search: true };
  static enabled = new Set();

  static {
    if (process.env.NODE_ENV === 'test') {
      this.enabled.add('test-mode');
    }
  }
}

The exact available globals depend on the environment, but the ownership rule remains: FeatureFlags.defaults belongs to the constructor. Be careful with mutable static collections because every instance and caller sees the same collection. A static cache can be intentional; an accidental shared array is a bug.

The common debugging checklist

When a class behaves strangely, ask which of these boundaries was crossed:

SymptomLikely question
this is undefinedWas the method detached from its instance?
Method is missing on an instanceWas it declared static by mistake?
Subclass constructor failsWas super() called before using this?
Parent behavior disappearedDid an override replace it instead of calling super?
Private field syntax failsIs the code inside the declaring class?
Instances change each otherIs mutable state shared outside the constructor or field initializer?

Use a small reproduction. Log Object.getPrototypeOf(instance), inspect instance.constructor, and compare the method location rather than guessing from the class syntax.

JavaScript classes are not fake, and they are not identical to classes in every other language. They provide class syntax, constructors, inheritance, public fields, static members, and private elements, while the object model still relies on prototypes and JavaScript’s rules for this. Developers who understand both layers can use classes deliberately instead of importing assumptions from another language.

Next step: compare these boundaries with Common JavaScript Mistakes Beginners Make or review JavaScript spread and rest when class state contains arrays and objects.

Choose the member location deliberately

One of the most practical class decisions is where a value or method lives. Instance members describe one object; static members describe the class itself; private fields are accessible only inside the class body. Methods defined in a class are placed on the prototype and shared through lookup rather than copied into every instance. MDN documents these distinctions, including constructors, fields, static members, private elements, and inheritance [1].

NeedBest starting pointQuestion to ask
State differs for every objectInstance field or constructor assignmentShould each instance own this value?
Behavior is shared by instancesInstance methodDoes the method operate on this?
Utility does not need an instanceStatic methodWhy should callers construct an object first?
Invariant must not be part of the public surfacePrivate field such as #tokenShould outside code be unable to read or replace it?
Two types share a contractComposition or carefully chosen inheritanceIs the relationship truly “is a,” or only “uses”?

Debug the instance, prototype, and receiver separately

When a method behaves unexpectedly, inspect whether the property exists on the instance or its prototype and whether the method was called with the expected receiver. Detaching a method can change this:

const counter = {
  value: 1,
  show() { return this.value; }
};

const show = counter.show;
show(); // `this` is not the counter object

An arrow function does not create its own dynamic this; it captures the surrounding one. That can be useful for callbacks and wrong for methods that need the calling object. Test the call form, not only the method body.

Inheritance connects prototypes, not copied classes

extends links the derived class to a prototype chain. If a derived constructor exists, it must call super() before using this. Inheritance can express a stable subtype relationship, but composition is often clearer when one object merely delegates to another. Do not introduce a hierarchy to avoid passing one dependency explicitly.

For related foundations, see spread and rest behavior, Promises and async/await, and common JavaScript mistakes. The right abstraction is the one whose state, ownership, and lifecycle remain explainable.

Class-design review checklist

  • Separate class-level state from instance-level state.
  • Confirm whether a method depends on the receiver and how it is called.
  • Use private fields only when the boundary provides real value.
  • Prefer composition when the relationship is “uses,” not “is a.”
  • Test initialization, inheritance, detached methods, and missing fields.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top