JavaScript · 10 topics

Objects, prototypes and classes

JavaScript has no classes underneath, only objects pointing at objects. The ten topics that explain what class actually does.

Published
Reading time
20 min read
Topics covered
10 topics

JavaScript has no classes underneath. It has objects that point at other objects. class is a nicer way to write that arrangement, not a different one.

Interviewers ask about this constantly, because understanding the prototype chain is what separates someone who has used class from someone who knows what it does.

The prototype chain

In simple words: every object has a hidden link to another object. When you ask for a property that is not there, JavaScript follows that link and asks the next one. It keeps going until it finds the property or runs out of links.

That hidden link is the object's prototype.

rexname: "Rex"Dog.prototypebark()Animal.prototypeeat()ObjecttoString()LOOKING UP rex.toString()not on rex, not on Dog, not on Animal, found on Object.prototypeObject.prototype.proto === nullThe chain ends there. A miss returns undefined rather than throwing.
Property lookup walks the chain one link at a time and stops at the first match. Reaching null without a match gives undefined.

prototype versus __proto__

These two names cause most of the confusion, and they are not the same thing.

What it isLives on
prototypethe object that will become the prototype of instances made with newfunctions only
__proto__the actual link to this object's prototypeevery object
function Dog(name) { this.name = name; }
Dog.prototype.bark = function () { return `${this.name} says woof`; };
 
const rex = new Dog("Rex");
 
rex.__proto__ === Dog.prototype;                  // true
Object.getPrototypeOf(rex) === Dog.prototype;     // true, and this is the modern way
rex.prototype;                                    // undefined -- instances have no `prototype`
Dog.prototype.constructor === Dog;                // true

new, and how to implement it

In simple words: new does four things. Knowing them is how you answer "what does new actually do?".

  1. Create an empty object.
  2. Set that object's prototype to the constructor function's prototype.
  3. Run the constructor with this bound to the new object.
  4. Return the new object — unless the constructor explicitly returned an object of its own.
function myNew(Constructor, ...args) {
  // 1 and 2
  const instance = Object.create(Constructor.prototype);
 
  // 3
  const result = Constructor.apply(instance, args);
 
  // 4 -- an explicitly returned object wins, anything else is ignored
  return result !== null && typeof result === "object" ? result : instance;
}
 
function Dog(name) { this.name = name; }
Dog.prototype.bark = function () { return `${this.name} says woof`; };
 
const rex = myNew(Dog, "Rex");
rex.bark();                  // "Rex says woof"
rex instanceof Dog;          // true

Classes

In simple words: class is syntax over the same prototype machinery. Methods you write in a class body land on Ferrari.prototype, exactly as if you had assigned them by hand.

class Animal {
  static kingdom = "Animalia";     // on the class, not on instances
  #secret = "hidden";              // truly private, not just a convention
 
  constructor(name) {
    this.name = name;              // own property, on each instance
  }
 
  eat() {                          // goes on Animal.prototype
    return `${this.name} is eating`;
  }
 
  get label() {                    // getter, accessed without parentheses
    return `Animal: ${this.name}`;
  }
 
  reveal() { return this.#secret; }
}
 
class Dog extends Animal {
  constructor(name, breed) {
    super(name);                   // must run before touching `this`
    this.breed = breed;
  }
 
  eat() {
    return `${super.eat()}, noisily`;
  }
}
 
const rex = new Dog("Rex", "Lab");
rex.eat();                         // "Rex is eating, noisily"
Object.getPrototypeOf(Dog.prototype) === Animal.prototype;  // true
rex.secret;                        // undefined
rex.#secret;                       // SyntaxError -- not accessible at all

What is genuinely new, not just sugar

Interviewers like the question "are classes just syntactic sugar?". The honest answer is mostly, but not entirely:

  • Class bodies always run in strict mode.
  • Class declarations are not hoisted usably — they sit in the temporal dead zone.
  • Calling a class without new throws. A constructor function would silently misbehave.
  • Methods are non-enumerable, so they do not appear in for...in.
  • #private fields have no equivalent in the old syntax. They are enforced by the engine.
  • super works through a hidden link that hand-written prototypes cannot reproduce exactly.

Prototypal versus classical inheritance

In simple words: in a classical language a class is a blueprint and objects are stamped from it. In JavaScript an object simply delegates to another object at runtime.

ClassicalPrototypal
Classes are templates, separate from instancesPrototypes are ordinary objects
Copying happens at instantiationNothing is copied — lookup delegates at runtime
The hierarchy is fixed at compile timeThe chain can be changed while running
Method resolution decided ahead of timeMethod resolution walks the chain on each miss

The practical consequence: adding a method to a prototype affects every existing instance immediately, because nothing was ever copied.

const rex = new Dog("Rex");
Dog.prototype.sit = function () { return `${this.name} sits`; };
rex.sit();   // "Rex sits" -- rex was created before sit existed

Object.create and friends

In simple words: Object.create makes a new object and lets you choose its prototype directly, with no constructor involved.

const animal = {
  eat() { return `${this.name} is eating`; },
};
 
const dog = Object.create(animal);   // dog.__proto__ === animal
dog.name = "Rex";
dog.eat();                           // "Rex is eating"
 
Object.getPrototypeOf(dog) === animal;  // true
 
// A dictionary with no inherited keys at all
const lookup = Object.create(null);
lookup.toString;                     // undefined -- no Object.prototype

Property descriptors

In simple words: every property has three hidden switches besides its value. Normally they are all on. Object.defineProperty lets you turn them off.

SwitchWhat it controls
writablecan the value be changed
enumerabledoes it show up in Object.keys and for...in
configurablecan it be deleted or have its descriptor changed
const user = {};
 
Object.defineProperty(user, "id", {
  value: 42,
  writable: false,
  enumerable: false,
  configurable: false,
});
 
user.id = 99;              // silently ignored in sloppy mode, throws in strict
Object.keys(user);         // []  -- hidden from enumeration
JSON.stringify(user);      // "{}"
user.id;                   // 42
 
Object.getOwnPropertyDescriptor(user, "id");
// { value: 42, writable: false, enumerable: false, configurable: false }

freeze, seal and preventExtensions

In simple words: three levels of locking an object, from loosest to tightest.

Add new propertiesDelete propertiesChange existing values
preventExtensionsnoyesyes
sealnonoyes
freezenonono
const config = Object.freeze({ retries: 3, nested: { timeout: 5000 } });
 
config.retries = 5;          // ignored, or throws in strict mode
config.nested.timeout = 1;   // WORKS -- freeze is only one level deep
 
Object.isFrozen(config);         // true
Object.isFrozen(config.nested);  // false

Deep freeze

function deepFreeze(obj) {
  for (const value of Object.values(obj)) {
    if (value && typeof value === "object" && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }
  return Object.freeze(obj);
}

Getters, setters and the Object statics

In simple words: a getter looks like a property but runs a function. Useful for derived values that should not be stored.

const order = {
  items: [{ price: 100 }, { price: 250 }],
 
  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  },
 
  set discount(percent) {
    if (percent < 0 || percent > 100) throw new RangeError("0 to 100 only");
    this._discount = percent;
  },
};
 
order.total;          // 350 -- no parentheses, recalculated every read
order.discount = 10;  // runs the setter, with validation

The statics worth knowing cold:

Object.keys(obj);        // own enumerable string keys
Object.values(obj);      // their values
Object.entries(obj);     // [key, value] pairs -- the one you use most
Object.fromEntries(arr); // the reverse, great after a filter or map
Object.assign(a, b);     // shallow merge into a, mutating it
Object.hasOwn(obj, k);   // modern replacement for hasOwnProperty
 
// A common real pattern: filter an object by its values
const cleaned = Object.fromEntries(
  Object.entries(params).filter(([, value]) => value !== undefined)
);

Mixins and composition

In simple words: JavaScript has single inheritance — one prototype link per object. When you need behaviour from two places, you compose instead of inherit.

const canFly = {
  fly() { return `${this.name} takes off`; },
};
 
const canSwim = {
  swim() { return `${this.name} dives in`; },
};
 
class Duck {
  constructor(name) { this.name = name; }
}
 
Object.assign(Duck.prototype, canFly, canSwim);
 
new Duck("Donald").fly();   // "Donald takes off"

Proxy and Reflect

In simple words: a Proxy wraps an object and lets you intercept the basic operations on it — reading, writing, deleting, checking existence.

const target = { name: "Subham" };
 
const safe = new Proxy(target, {
  get(obj, prop, receiver) {
    if (!(prop in obj)) throw new ReferenceError(`No property "${String(prop)}"`);
    return Reflect.get(obj, prop, receiver);
  },
  set(obj, prop, value, receiver) {
    if (prop === "name" && typeof value !== "string") {
      throw new TypeError("name must be a string");
    }
    return Reflect.set(obj, prop, value, receiver);
  },
});
 
safe.name;        // "Subham"
safe.missing;     // ReferenceError instead of undefined
safe.name = 42;   // TypeError

Reflect provides the default behaviour for each trap, so you can add a check and then hand off. Writing obj[prop] = value directly inside a set trap loses the correct receiver and breaks inheritance — Reflect.set does not.

This is not everyday code, but it powers real tools: Vue 3 reactivity, Immer, and most mocking libraries are built on Proxy.

Ten questions

  1. 01
    What is the difference between prototype and __proto__?
  2. 02
    Describe exactly what new does, in four steps.
  3. 03
    Are ES6 classes just syntactic sugar over prototypes?
  4. 04
    You add a method to Dog.prototype after creating an instance. Can that instance use it? Why?
  5. 05
    Why would you use Object.create(null) instead of ?
  6. 06
    What do writable, enumerable and configurable control?
  7. 07
    What is the difference between freeze, seal and preventExtensions?
  8. 08
    Why is extending Array.prototype considered dangerous?
  9. 09
    What does "composition over inheritance" mean here, concretely?
  10. 10
    Why use Reflect.set inside a Proxy set trap rather than assigning directly?

Six exercises

  1. 01

    Implement myNew(Constructor, ...args)

    Replicate all four steps of new. Prove it works by checking instanceof and by writing a constructor that explicitly returns an object, then one that returns a number — the behaviours must differ.

  2. 02

    Build the same class twice

    Write an Animal with a Dog subclass using ES6 class. Then write the identical structure using only constructor functions, Object.create and explicit prototype assignment. Note every difference you had to work around.

  3. 03

    Write deepFreeze

    Freeze every level. Then make it survive an object that contains a circular reference — you will need to track what you have already visited.

  4. 04

    Hide a property completely

    Use Object.defineProperty to add an id that cannot be changed, does not appear in Object.keys, and is skipped by JSON.stringify. Then find the one method that still reveals it.

  5. 05

    Compose instead of inherit

    Model a Duck that can both fly and swim, without any inheritance chain. Then add a Penguin that swims but does not fly, and show why an inheritance-based version would have been awkward.

  6. 06

    Validating Proxy

    Wrap a plain object so that reading a missing property throws instead of returning undefined, and writing a wrong type throws. Use Reflect for the default behaviour, and explain in a comment why direct assignment would be wrong.

Common questions

What is the difference between prototype and __proto__?
prototype is a property that exists only on functions. It holds the object that will become the prototype of instances created with new. __proto__ exists on every object and is the actual link to that object's prototype. So for an instance rex of Dog, rex.__proto__ equals Dog.prototype. Use Object.getPrototypeOf rather than __proto__ in real code.
What does the new keyword actually do in JavaScript?
It performs four steps: creates an empty object, sets that object's prototype to the constructor function's prototype property, calls the constructor with this bound to the new object, and returns the object. If the constructor explicitly returns an object, that object is returned instead; a returned primitive is ignored.
Are ES6 classes just syntactic sugar over prototypes?
Mostly, but not entirely. Methods still live on the prototype and inheritance is still a prototype link. However class bodies are always in strict mode, the binding sits in the temporal dead zone, calling a class without new throws, methods are non-enumerable, and private fields marked with a hash are enforced by the engine rather than being a naming convention.
Why use Object.create(null) instead of an object literal?
Object.create(null) creates an object with no prototype, so it inherits nothing - no toString, no constructor, no __proto__. That matters for lookup tables keyed by user input, where a key named __proto__ or constructor would otherwise collide with inherited members. This collision is the basis of prototype pollution bugs. A Map is often the better choice.
What is the difference between Object.freeze, seal and preventExtensions?
preventExtensions blocks adding new properties. seal also blocks deleting existing ones. freeze also blocks changing their values. All three are only one level deep, so a frozen object's nested objects remain fully mutable unless you freeze them recursively.