skip to content

Classes as Prototype Sugar

What a class declaration desugars to, and where it deliberately differs from the equivalent function-plus-prototype code. Being able to write that ES5 equivalent on a whiteboard is the standard way this gets checked.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In JavaScript, when you write `class Point { constructor(x) {} move(dx) {} }`, where does `move` actually live, and how does that differ from writing `Point.prototype.move = function (dx) {}` in the older constructor-function style?

level: middleimportance: must knowfreq 72%

answer

  1. same place, different descriptor
  2. one style leaks into for...in
  3. enumerable is false on class methods
  4. assignment sets enumerable true
  5. no [[Construct]] slot on class methods

basics

~20 s

Both forms put move on Point.prototype, so lookup is identical. The class defines it as non-enumerable and non-constructible, so for...in and Object.assign skip it, while the hand-written assignment creates an enumerable, constructible property they both pick up.

solid answer

~40 s

Both forms end up with `move` as a property of `Point.prototype`, shared by every instance rather than copied onto each one — that part genuinely is sugar. The difference is the property descriptor. A class method is installed the way `Object.defineProperty` would install it: `enumerable: false`, `writable: true`, `configurable: true`. The hand-written `Point.prototype.move = ...` is a plain assignment, so it is enumerable, and it therefore shows up in `for...in` over an instance and in `Object.assign(target, Point.prototype)`; the class method shows up in neither. Class methods also lack a `[[Construct]]` slot, so `new p.move()` throws a `TypeError`, whereas the assigned function expression is constructible and even carries its own unused `.prototype` object. Finally, a class's own `prototype` property is non-writable, so you cannot replace it wholesale. `Point.prototype.constructor` still points back at `Point` in both.

code

javascript · 14 lines
javascript
class Point { move() {} }

function Legacy() {}
Legacy.prototype.move = function () {};

console.log(Object.getOwnPropertyDescriptor(Point.prototype, 'move').enumerable);  // false
console.log(Object.getOwnPropertyDescriptor(Legacy.prototype, 'move').enumerable); // true

const keys = (o) => { const out = []; for (const k in o) out.push(k); return out; };
console.log(keys(new Point()));  // []
console.log(keys(new Legacy())); // ['move']

console.log(Object.assign({}, Point.prototype));  // {}
console.log(Object.assign({}, Legacy.prototype)); // { move: [Function] }

go deeper

for a junior

Be ready to say that methods written in a class body live on the prototype and are shared by every instance, and that a class is still a function under the hood.

for a middle

Explain the descriptor concretely: defined rather than assigned, so enumerable is false while writable and configurable stay true, and show the for...in difference that follows.

for a senior

Demonstrate the bugs you have actually hit — prototype-copying helpers and mixin utilities that silently produce nothing once the source becomes a class, and methods that cannot be invoked with new.

for a principal

Own the API consequence: non-enumerable-by-default is what keeps class-based objects safe for code that copies enumerable properties, so treat defining methods by plain assignment as a deliberate, justified exception.

## What the declaration actually creates A class declaration creates exactly one function object. `class Point { constructor(x) { this.x = x } move(dx) { this.x += dx } }` produces a function named `Point` whose body is the `constructor` body; every other method in the class body becomes a property of `Point.prototype`; `Point.prototype.constructor` refers back to `Point`; and `new Point(1)` produces an object whose internal prototype is `Point.prototype`, so `p.move(2)` is resolved by walking the prototype chain rather than found on the instance. `typeof Point` is `"function"`. That is the same object graph the pre-class pattern builds by hand: ```js function Point(x) { this.x = x; } Point.prototype.move = function (dx) { this.x += dx; }; ``` So the shape is identical. What differs is *how* the method property is installed, and a handful of restrictions the class form adds. ## The descriptor difference A method in a class body is defined, not assigned — the effect is the same as `Object.defineProperty(Point.prototype, 'move', { value: fn, writable: true, enumerable: false, configurable: true })`. A hand-written `Point.prototype.move = fn` is an ordinary assignment, and assignment creates a data property with `writable`, `enumerable` and `configurable` all `true`. The single practical bit is `enumerable`: ```js Object.keys(Point.prototype); // [] Object.getOwnPropertyNames(Point.prototype); // ['constructor', 'move'] ``` ## Why non-enumerability matters `for...in` iterates enumerable string-keyed properties *including inherited ones*. With the ES5 pattern, `for (const k in p)` yields `'move'` alongside the instance's own data — which is exactly why old code is littered with `if (!obj.hasOwnProperty(k)) continue;`. With a class, the loop yields only the instance's own properties, and the guard is unnecessary. The same rule drives copying helpers. `Object.assign` and object spread copy *own enumerable* properties, so `Object.assign({}, Legacy.prototype)` carries the methods over, while `Object.assign({}, Point.prototype)` copies nothing at all. Anyone who wrote a hand-rolled "mix these methods in" helper on top of `Object.assign` finds it silently produces empty objects the day the source becomes a class. The fix is to read keys with `Object.getOwnPropertyNames` (or `Reflect.ownKeys`, which also returns symbols) and copy descriptors with `Object.getOwnPropertyDescriptor` / `Object.defineProperty`. The class default is not arbitrary: the built-ins behave the same way. `Array.prototype.map` and `Object.prototype.toString` are non-enumerable, which is why `for...in` over an array does not list `map`. Class syntax simply made user code match the platform. ## Methods are not constructors A method defined in a class body (like a shorthand method in an object literal) has no `[[Construct]]` internal method: ```js const p = new Point(1); new p.move(); // TypeError: p.move is not a constructor ``` The assigned function expression in the ES5 version *is* constructible, and it additionally allocates its own `.prototype` object that nothing ever uses. Class methods also have no `prototype` property at all. ## The prototype property itself For an ordinary function, `Foo.prototype` is `{ writable: true, enumerable: false, configurable: false }` — the classic `Foo.prototype = { ... }` wholesale replacement works (and quietly loses `constructor` unless you restore it). For a class, `prototype` is `{ writable: false, enumerable: false, configurable: false }`, so `Point.prototype = {}` throws a `TypeError` in strict code and fails silently in sloppy code. You can still patch individual members — class methods are writable and configurable, so `Point.prototype.move = betterMove` and `delete Point.prototype.move` both work. ## What is genuinely the same Method lookup through the chain, `instanceof`, the single shared copy of each method, and the ability to monkey-patch the prototype after the fact are all unchanged. The mental model "a class is a constructor function plus a prototype object" stays correct; what you must add is "…whose methods are defined rather than assigned, and whose function object refuses a few things an ordinary function allows."

  • If class methods are non-enumerable, how do you list them at run time?
    Use `Object.getOwnPropertyNames(C.prototype)`, which ignores enumerability, and drop `'constructor'`; add `Object.getOwnPropertySymbols` or use `Reflect.ownKeys` if symbol-keyed methods matter. To include inherited methods, repeat that on each object returned by `Object.getPrototypeOf` until you reach `Object.prototype`.
  • Does `JSON.stringify` of an instance differ between the two styles?
    No. `JSON.stringify` serializes only own enumerable string-keyed properties, and prototype methods are not own properties in either style — function values are skipped anyway. The observable differences are `for...in`, `Object.assign`/spread over the prototype object, and `Object.keys(C.prototype)`.
  • Why did the language make class methods non-enumerable rather than matching assignment?
    It matches the built-ins: `Array.prototype.map` and friends have always been non-enumerable, so instances iterate cleanly. Enumerable prototype methods were a long-standing nuisance — every `for...in` loop needed a `hasOwnProperty` guard, and property-copying helpers dragged behaviour along with data.

saying these in an interview costs you the question

  • Says each instance gets its own copy of the method
  • Claims classes replaced prototypes with a different object model
  • Assumes Object.assign copies methods off a class prototype
  • Thinks for...in never walks the prototype chain
  • Believes any function-valued property can be called with new

context

open as a page

In JavaScript, what happens when you invoke a class constructor without `new` — for example `Point(1, 2)` where `Point` was declared with the `class` keyword — and how did the old constructor-function style behave in the same situation?

level: juniorimportance: should knowfreq 58%

basics

~20 s

It throws a TypeError immediately: class constructors cannot be invoked without new, and the throw happens before any constructor body runs. An old-style constructor function instead ran normally, with this bound to undefined or the global object.

open as a page

In JavaScript, `new Widget()` written above `function Widget() {}` works, but the same call above `class Widget {}` throws. Explain what a class declaration does differently.

level: middleimportance: should knowfreq 48%

basics

~20 s

A class declaration is hoisted but stays uninitialized until evaluated, so touching it earlier throws ReferenceError: Cannot access before initialization. A function declaration is hoisted and initialized with the function, so it is callable from anywhere in its scope.

open as a page

In JavaScript, a class body is strict-mode code even inside a plain non-module script that never wrote 'use strict'. What concretely changes for code inside the class, and what tends to break when an old constructor function is rewritten as a class?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Every part of a class definition is strict code with no opt-out, so undeclared assignments throw ReferenceError, previously silent write failures become TypeErrors, and legacy sloppy idioms are rejected outright — even when the surrounding script never opted in.

open as a page

In JavaScript, what does the identifier `Inner` refer to in `const Outer = class Inner { who() { return Inner.name; } };`, and where is it visible?

level: middleimportance: nice to knowfreq 28%

basics

~10 s

Inner is a binding visible only inside the class body, not in the surrounding scope, where the class is reachable only as Outer. That inner binding is immutable, and Outer.name is the string 'Inner'.

open as a page