Learn JS Series (#37) - Constructor Functions and the new Operator, Step by Step

scipio(71)
Published in
#blog
Words
2590
Reading
12 min
Listen
Play
1h

Learn JS Series (#37) - Constructor Functions and the new Operator, Step by Step

js-banner.png

What will I learn

  • You will learn what a constructor function is, and the naming convention that signals one;
  • exactly what the new operator does, in four precise steps;
  • what the prototype property on a function is, and how it becomes an instance's prototype;
  • how to put shared methods on the constructor's prototype;
  • what goes wrong if you forget new, and how to reason about it.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-36 read, especially prototypes and Object.create.

Difficulty

  • Intermediate

Curriculum (of the Learn JS Series):

Learn JS Series (#37) - Constructor Functions and the new Operator, Step by Step

Last episode we built inheritance with our bare hands, wiring one object to another with Object.create and nothing else. That was the modern honest road to prototypal inheritance. Today we walk the older road, the one that JavaScript shipped with back in 1995 and that every codebase from that era is built on: constructor functions and the new operator. And here is the reassuring part -- it leads to exactly the same destination. Same prototype chain, same shared methods, same per-object data. Only the spelling changes.

Why bother with the old road at all, when class is right around the corner? Because class is sugar over this, not over Object.create. When you finally write class in the next episode, every line of it will map onto a constructor function and a prototype object -- the very two things we take apart today. So think of this episode as the last piece of scaffolding before the pretty facade goes up. Having said that, let's build it properly. ;-)

Solutions to Episode 36 Exercises

Exercise 1 - a shape and a circle:

const shape = {
  init(name) { this.name = name; return this; },
  area() { return 0; },
};
const circle = Object.create(shape);
circle.area = function () { return Math.PI * this.radius ** 2; };
const c = Object.create(circle).init("disc");
c.radius = 2;
console.log(c.name, c.area().toFixed(2)); // "disc" "12.57"

The insight: area is overridden on circle, while name still comes from shape's inherited init.

Exercise 2 - override that calls the parent:

const person = { greet() { return "hello"; } };
const employee = Object.create(person);
employee.greet = function () {
  return `${person.greet.call(this)}, I am a ${this.title}`;
};
const e = Object.create(employee);
e.title = "engineer";
console.log(e.greet()); // "hello, I am a engineer"

The insight: person.greet.call(this) runs the inherited version with the current this, then we extend it, exactly what super will do.

Exercise 3 - create with descriptors:

const proto = { hi() { return `hi ${this.name}`; } };
const obj = Object.create(proto, {
  name: { value: "scipio", enumerable: true },
  secret: { value: 1, enumerable: false },
});
console.log(Object.keys(obj), obj.hi(), obj.secret); // ["name"] "hi scipio" 1

The insight: one Object.create call both linked the prototype and defined own properties with chosen flags.

Now the older, function-based road to that same place, and the operator that makes it work.

Constructor functions: a convention, not a keyword

Before Object.create and long before class, JavaScript's original way to stamp out many similar objects was the constructor function. Here is the thing that trips people up, so I will say it plainly: a constructor function is just an ordinary function. There is no special constructor keyword, no distinct kind of function object, nothing the parser treats differently. What makes a function "a constructor" is purely how you call it -- with new in front -- plus a naming convention the whole community agreed on.

That convention is: a constructor's name is capitalized (PascalCase), precisely to signal "call me with new":

function User(name, level) {
  this.name = name;   // 'this' is the new object (thanks to new)
  this.level = level;
  this.active = true;
}

const alice = new User("alice", 5);
const bob = new User("bob", 3);
console.log(alice); // User { name: 'alice', level: 5, active: true }
console.log(bob);   // User { name: 'bob', level: 3, active: true }

Called with new, User builds a fresh object, assigns properties to it through this, and hands it back. Two short lines produce two completely independent objects -- alice and bob share no state, each has its own name, level, and active. Notice also that the console prints User { ... } and not just { ... }: the engine remembers which constructor made the object, a small detail we will cash in later when we talk about instanceof.

But look at how much is happening implicitly. We never wrote const obj = {}. We never wrote return obj. Yet alice is a fully formed object. Where did it come from, how did this become it, and who returned it? That is the new operator doing four quiet jobs on your behalf, and it is genuinely worth knowing each one.

What new does, in four steps

When you evaluate new User("alice", 5), JavaScript performs four precise steps. Learn these four and the mystery evaporates completely:

  1. Create a brand new empty object.
  2. Link that new object's prototype to User.prototype (the function's prototype property, explained in the next section).
  3. Call User with this bound to the new object, running its body (which sets properties on this).
  4. Return the new object automatically -- unless the constructor explicitly returns an object of its own, in which case that object wins.

The best way to convince yourself there is no magic here is to rebuild new out of tools you already own. Everything below uses only Object.create (episode 36) and apply (episode 23):

function User(name) {
  this.name = name;
}

// what `new User("scipio")` effectively does, by hand:
function manualNew(Constructor, ...args) {
  const obj = Object.create(Constructor.prototype); // steps 1 and 2
  const result = Constructor.apply(obj, args);       // step 3
  return typeof result === "object" && result !== null ? result : obj; // step 4
}

const a = new User("scipio");
const b = manualNew(User, "scipio");
console.log(a.name, b.name); // "scipio" "scipio" - identical behaviour

Trace it line by line. Object.create(Constructor.prototype) creates the empty object and links its prototype in one go -- that is steps 1 and 2 fused, which is exactly why episode 36 was such useful groundwork. Constructor.apply(obj, args) calls the function with this set to our new object and the arguments spread in -- step 3. And the final line is step 4: if the constructor happened to return its own object we hand that back, otherwise we hand back the object we built. new is nothing more than this sequence, wrapped up in a single keyword. You have already seen every ingredient.

That step-4 subtlety is worth a quick demonstration, because it catches people out. If a constructor returns a primitive (a number, a string, undefined), that return value is ignored and you still get the new object. But if it returns an object, that object replaces the one new would have given you:

function Weird() {
  this.a = 1;
  return { b: 2 }; // returning an object overrides the default
}
function Fine() {
  this.a = 1;
  return 42; // returning a primitive is ignored
}
console.log(new Weird()); // { b: 2 } - our object was thrown away!
console.log(new Fine());  // Fine { a: 1 } - the 42 was ignored

You will almost never want to return an object from a constructor (it defeats the point), but knowing the rule means you will never be baffled by it either.

The prototype property

Step 2 mentioned User.prototype, and this is the single most confusing word in the whole language, so let us nail it down hard. Every regular function -- yes, every one, whether you ever use it as a constructor or not -- automatically comes with a property literally called prototype, and that property holds an object. When you use the function with new, the newly created instance's internal prototype link is pointed at that prototype object. In other words, the function's prototype property becomes the shared prototype of all the instances it creates:

function Dog(name) {
  this.name = name;
}
const rex = new Dog("Rex");

// the instance's prototype IS Dog.prototype:
console.log(Object.getPrototypeOf(rex) === Dog.prototype); // true

You must hold two different meanings of the word "prototype" apart, or this will forever feel slippery. Dog.prototype is a property sitting on the function object -- think of it as the blueprint that instances will point at. Object.getPrototypeOf(rex) is the instance's actual live prototype link -- the thing property lookup climbs. The new operator is the bridge between them: it makes the second point at the first. Dog.prototype is the blueprint; rex's hidden link is the wire; new solders the wire to the blueprint.

There is one more strand on that prototype object, and it closes a nice little loop. By default, Dog.prototype has a property called constructor that points right back at Dog. Because rex inherits from Dog.prototype, rex.constructor finds it too:

console.log(Dog.prototype.constructor === Dog); // true
console.log(rex.constructor === Dog);           // true - inherited from Dog.prototype
console.log(rex.constructor.name);              // "Dog"

So an object can, in principle, tell you which constructor built it. Do not lean on this too heavily (it can be reassigned, and inheritance can break it if you are careless), but it is genuinely handy for debugging and it explains that Dog { ... } label the console printed earlier.

Shared methods on the prototype

Here is the real payoff of the whole model, and the reason constructor-plus-prototype beat "just put everything in the constructor". If you assign methods inside the constructor body, like this.speak = function () {...}, then every single instance gets its own private copy of that function. Ten instances, ten identical copies of speak; ten thousand instances, ten thousand copies. That is wasteful, and pointless, because the method never changes per object.

The fix is to put shared behaviour on the constructor's prototype object exactly once, so all instances inherit one shared copy through the prototype chain:

function Dog(name) {
  this.name = name; // per-instance data goes on 'this'
}

// shared behaviour goes on the prototype: ONE copy for ALL instances:
Dog.prototype.speak = function () {
  return `${this.name} says woof`;
};
Dog.prototype.legs = 4;

const rex = new Dog("Rex");
const fido = new Dog("Fido");
console.log(rex.speak());              // "Rex says woof"
console.log(fido.speak());             // "Fido says woof"
console.log(rex.speak === fido.speak); // true - the SAME shared function

Study that last line, because it proves the whole point. rex and fido each carry their own name, stamped on by the constructor -- but rex.speak and fido.speak are the identical function object, living up on Dog.prototype. The === returns true because there is only one speak in memory, inherited by both, not copied into either. When you call rex.speak(), the engine does not find speak on rex, hops one link up to Dog.prototype, finds it, and runs it with this bound to rex -- which is why this.name reads "Rex". This is precisely the memory-efficient sharing we saw in episode 34, now driven by a constructor instead of a hand-rolled Object.create.

The rule to carry with you forever: per-instance data goes on this inside the constructor; shared behaviour goes on the prototype. Data down here, behaviour up there. Break that rule and you either waste memory (methods on this) or accidentally share mutable state between all instances (mutable data on the prototype, a classic beginner trap -- put an array on Dog.prototype and every dog shares the same array).

Forgetting new

Because a constructor really is just a function, JavaScript will happily let you call it without new -- and the result is a quiet little disaster. Remember the four steps? Without new, none of them happen. No new object is created, this is not bound to a fresh object (it is undefined in strict mode, or the global object in sloppy mode), and the function's return value is used as-is -- which is undefined, since our constructor has no return:

"use strict";
function User(name) {
  this.name = name;
}
const good = new User("scipio"); // correct: an object
console.log(good.name);          // "scipio"

// const bad = User("scipio"); // ERROR (runtime): Cannot set properties of undefined
// without new, 'this' is undefined in strict mode, so this.name throws

In strict mode this throws immediately, which is actually the kind outcome -- you find the bug at once. In old sloppy-mode code it was far nastier: this became the global object, so this.name = name silently created a global variable name, your bad variable got undefined, and nothing complained until something far away broke. This "oops, forgot new" footgun was common enough that it directly motivated the class syntax we meet next episode: a class simply cannot be called without new -- it throws a clear TypeError the instant you try -- removing the trap entirely.

Until then, the capitalization convention is your manual guard. PascalCase names (User, Dog, Point) are a signal to every reader, including future-you at midnight, that this function must be called with new. It is not enforced by the engine, it is enforced by discipline -- but it is a discipline worth keeping.

A quick look sideways: the same idea in Python

Quite a few of you arrived here from the Learn Python Series, so the comparison is genuinely clarifying -- it shows JavaScript's constructor is one answer among several to a universal need: set up a new object and give it shared behaviour.

In Python, __init__ is doing the exact job of our constructor body (stamping per-instance data onto self), and the class block itself is what holds the shared methods -- the equivalent of our prototype object:

class Dog:
    legs = 4                       # class attribute: shared, like Dog.prototype.legs
    def __init__(self, name):
        self.name = name           # per-instance data, like this.name
    def speak(self):
        return f"{self.name} says woof"

rex = Dog("Rex")
fido = Dog("Fido")
print(rex.speak())                 # "Rex says woof"
print(rex.speak == fido.speak)     # methods resolved on the class, not copied

The mapping is almost one-to-one: __init__ is the constructor body, self is this, class attributes are prototype properties, and Dog("Rex") (no new needed in Python) is the whole new Dog("Rex") dance. The philosophical difference is that Python bakes a formal class object into the language from the start, whereas JavaScript hands you a plain function plus a plain prototype object and lets you wire them -- the same wiring we did by hand with manualNew. Seeing that both languages solve the same problem with different clothing is exactly what makes JavaScript's class (next episode) feel like a natural convergence rather than a random bolt-on.

Try it yourself

  1. Write a constructor function Book(title, author) that sets this.title and this.author. Add a shared method summary() on Book.prototype returning "title by author". Create two books and confirm they share the same summary function with ===.
  2. Implement your own manualNew(Constructor, ...args) (as in the episode) and use it to create an instance of your Book. Verify it behaves identically to using new, and confirm the instance's prototype is Book.prototype and that instance.constructor points at Book.
  3. Write a constructor and call it once with new and once without (in strict mode). Describe what happens in each case, and explain in one sentence which of the four new steps are skipped when you forget it.

So what did we actually cover?

  • A constructor function is an ordinary function meant to be called with new, conventionally named in PascalCase -- the capitalization is the only "constructor-ness" there is.
  • new does four steps: create an empty object, link its prototype to the function's prototype property, call the function with this as that object, and return it (unless the body returns its own object).
  • You can replicate new by hand with Object.create and apply, proving it is not magic -- just a packaged sequence.
  • A function's prototype property becomes the shared prototype of every instance created with new; Object.getPrototypeOf(instance) === Constructor.prototype, and prototype.constructor points back at the function.
  • Put per-instance data on this and shared methods on Constructor.prototype, so all instances inherit one copy (memory-efficient), and never put mutable data on the prototype by accident.
  • Forgetting new breaks everything (bad this, no object returned); PascalCase guards against it by convention, and class removes the footgun entirely.

Next episode we finally reach the class keyword, and you will see it is pure syntactic sugar over exactly the constructor-and-prototype machinery we just assembled by hand.

That's it for this one, thanks for reading.

scipio@scipio

Learn JS Series (#37) - Constructor Functions and the new Operator,... | Ecency