Skip to content
academia.sh

Lesson 02 / 17

Prototypal Inheritance

The link constructor functions and the new operator set up, extending the prototype chain to two layers, type-query operations, and the link changing at runtime.

Contents

The previous lesson set up the prototype link by hand: shared behavior was gathered in one object, each record was bound to it with Object.create, and fields were filled in one by one. The pattern works, but it has two gaps. Filling in fields and binding behavior sit in separate places, and there is no built-in setup for deriving one record type from another.

The language offers a built-in path for both. This lesson opens that path — the constructor function and the new operator — then writes by hand what new does, to show that it is not magic but a shorthand for the three steps you saw in the previous lesson.

The Constructor Function

Every function in JavaScript carries an ordinary property named prototype. This property is not the function’s own prototype; it is the object that will become the prototype of the objects the function produces when called with new. Because the name similarity hides this distinction, the two concepts must not be confused.

A function written to be called with new is called a constructor function. It fills in fields through this, while behaviors are written to the prototype object.

function MeasurementRecord(sensor, value, time) {
  this.sensor = sensor;
  this.value = value;
  this.time = time;
}

MeasurementRecord.prototype.format = function () {
  return `${this.sensor}@${this.time}: ${this.value}`;
};

MeasurementRecord.prototype.exceedsThreshold = function (threshold) {
  return this.value > threshold;
};

const recordOne = new MeasurementRecord("S-01", 21.4, 1000);
const recordTwo = new MeasurementRecord("S-02", 19.8, 1060);

console.log(recordOne.format());
console.log(Object.getPrototypeOf(recordOne) === MeasurementRecord.prototype);
console.log(recordOne.format === recordTwo.format);
console.log(Object.keys(recordOne).join(","));
console.log(recordOne.constructor === MeasurementRecord);
console.log(Object.hasOwn(MeasurementRecord.prototype, "constructor"));
S-01@1000: 21.4
true
true
sensor,value,time
true
true

The result is the same as the previous lesson’s setup: data separate in each record, behavior in one place. The fourth line confirms this — the record’s own properties are only the three data fields.

The fifth and sixth lines expose a detail. Every function’s prototype object, the moment it is created, carries a property named constructor, and this property points at the function itself. The expression recordOne.constructor is not found on the record; it comes from the chain, through MeasurementRecord.prototype. So constructor is not an object’s “true type,” it is an ordinary property encountered on its chain — the cost of this distinction will be seen in the next section.

What the new Operator Does

The expression new MeasurementRecord(...) breaks down into four steps:

  1. An empty object is created whose prototype is MeasurementRecord.prototype.
  2. The constructor function is called with this bound to this object.
  3. If the constructor function returns an object, that object is the result; if not, the object from the first step is the result.
  4. The result becomes the value of the expression.

All of these steps are ordinary operations; none of them carries language-specific privilege. A function doing the same job can be written.

function MeasurementRecord(sensor, value, time) {
  this.sensor = sensor;
  this.value = value;
  this.time = time;
}
MeasurementRecord.prototype.format = function () {
  return `${this.sensor}@${this.time}: ${this.value}`;
};

function newInstance(Constructor, ...args) {
  const obj = Object.create(Constructor.prototype);
  const result = Constructor.apply(obj, args);
  return typeof result === "object" && result !== null ? result : obj;
}

const viaHelper = newInstance(MeasurementRecord, "S-01", 21.4, 1000);
const viaOperator = new MeasurementRecord("S-01", 21.4, 1000);

console.log(viaHelper.format());
console.log(Object.getPrototypeOf(viaHelper) === MeasurementRecord.prototype);
console.log(JSON.stringify(viaHelper) === JSON.stringify(viaOperator));
console.log(viaHelper instanceof MeasurementRecord);
S-01@1000: 21.4
true
true
true

The newInstance function stands in for the new operator and produces an indistinguishable result. The apply call explicitly sets up the this binding; this operation’s rules will be covered in the first two lessons of the Context and Closures topic. The real observation here is this: new is a shorthand laid on top of the prototype model, not a separate mechanism.

The newInstance function we wrote does not cover the whole of new: constructors defined with class syntax cannot be called without new, and details like new.target fall outside this shorthand. What matters for the model is the three lines above.

Extending the Chain

Consider a subtype of measurement records: a calibrated measurement carrying a sensor’s systematic drift. Its fields cover the measurement record’s fields and add an offset value on top; how the read value is computed differs, how it is formatted is the same.

Extending the chain to two layers requires two jobs: the subtype’s prototype has to be bound to the supertype’s prototype, and while the sub-constructor runs, the super-constructor’s field-filling job has to run too.

function MeasurementRecord(sensor, value, time) {
  this.sensor = sensor;
  this.value = value;
  this.time = time;
}
MeasurementRecord.prototype.format = function () {
  return `${this.sensor}@${this.time}: ${this.value}`;
};
MeasurementRecord.prototype.reading = function () {
  return this.value;
};

function CalibratedMeasurement(sensor, value, time, offset) {
  MeasurementRecord.call(this, sensor, value, time);
  this.offset = offset;
}

CalibratedMeasurement.prototype = Object.create(MeasurementRecord.prototype);
CalibratedMeasurement.prototype.constructor = CalibratedMeasurement;

CalibratedMeasurement.prototype.reading = function () {
  return this.value + this.offset;
};

const calibrated = new CalibratedMeasurement("S-03", 21.4, 1120, -0.4);

console.log(calibrated.format());
console.log(calibrated.reading());
console.log(MeasurementRecord.prototype.reading.call(calibrated));
console.log(Object.getPrototypeOf(calibrated) === CalibratedMeasurement.prototype);
console.log(Object.getPrototypeOf(CalibratedMeasurement.prototype) === MeasurementRecord.prototype);
console.log(calibrated.constructor === CalibratedMeasurement);
S-03@1120: 21.4
21
21.4
true
true
true

The chain now has four links: calibratedCalibratedMeasurement.prototypeMeasurementRecord.prototypeObject.prototypenull. format is not found on the second link, it is found on the third; reading is found on the second link, so it shadows the third’s definition. The polymorphism introduced in the Introduction to Object-Oriented Programming lesson of the Programming Fundamentals course is exactly this shadowing here: the same name corresponds to different implementations at different layers of the chain, and lookup order decides which one runs.

Three lines deserve attention. MeasurementRecord.call(this, ...) runs the super-constructor’s field-filling job on the new object. If the link set up with Object.create(MeasurementRecord.prototype) had instead been set up as CalibratedMeasurement.prototype = MeasurementRecord.prototype, the two types would share the exact same prototype object, and every behavior added to the subtype would leak into the supertype too. Writing the constructor property back by hand is the consequence of the first section’s observation: when the prototype object is replaced, constructor is lost along with it, and if it is not rewritten, calibrated.constructor finds MeasurementRecord from the chain.

Type Querying on the Chain

The instanceof operator asks whether the function on its right’s prototype object is found on the chain of the object on its left. This query, which looks like class membership, is actually a chain scan.

function MeasurementRecord(sensor, value) {
  this.sensor = sensor;
  this.value = value;
}
function CalibratedMeasurement(sensor, value, offset) {
  MeasurementRecord.call(this, sensor, value);
  this.offset = offset;
}
CalibratedMeasurement.prototype = Object.create(MeasurementRecord.prototype);
CalibratedMeasurement.prototype.constructor = CalibratedMeasurement;

const calibrated = new CalibratedMeasurement("S-03", 21.4, -0.4);
const plain = new MeasurementRecord("S-01", 21.4);

console.log(calibrated instanceof CalibratedMeasurement);
console.log(calibrated instanceof MeasurementRecord);
console.log(calibrated instanceof Object);
console.log(plain instanceof CalibratedMeasurement);

console.log(MeasurementRecord.prototype.isPrototypeOf(calibrated));
console.log(CalibratedMeasurement.prototype.isPrototypeOf(plain));

let stop = calibrated;
const chain = [];
while (stop !== null) {
  stop = Object.getPrototypeOf(stop);
  chain.push(stop === null ? "null" : stop.constructor.name);
}
console.log(chain.join(" -> "));
true
true
true
false
true
false
CalibratedMeasurement -> MeasurementRecord -> Object -> null

The last block walks the chain from start to end and prints it, making visible where the instanceof results come from. Because all three prototype objects are found on the calibrated measurement’s chain, all three queries answer positively; because the plain record does not carry the subtype’s prototype on its chain, the fourth query is negative. isPrototypeOf asks the same question from the reverse direction and looks not at the function but directly at the prototype object.

The prototype link is not a copy, it is a live reference. A property added to the prototype after an object is created immediately becomes visible on every existing object.

function MeasurementRecord(sensor, value) {
  this.sensor = sensor;
  this.value = value;
}
MeasurementRecord.prototype.format = function () {
  return `${this.sensor}: ${this.value}`;
};

const record = new MeasurementRecord("S-01", 21.4);
console.log(record.format());
console.log(typeof record.summarize);

MeasurementRecord.prototype.summarize = function () {
  return `${this.sensor} record`;
};

console.log(typeof record.summarize);
console.log(record.summarize());

const otherBehavior = {
  format() {
    return `[${this.sensor}] ${this.value}`;
  },
};
Object.setPrototypeOf(record, otherBehavior);
console.log(record.format());
console.log(record instanceof MeasurementRecord);
console.log(typeof record.summarize);
S-01: 21.4
undefined
function
S-01 record
[S-01] 21.4
false
undefined

summarize was added after the record was created and becomes accessible with no re-creation at all; because the search is done fresh on every read, the link is live.

Object.setPrototypeOf changes an existing object’s link. The consequences are broad: the record no longer sees the supertype’s behaviors, the instanceof result flips, and because runtimes optimize based on object shapes, access performance drops. For this reason the link is set up at the moment the object is created and is not changed afterward; Object.create or new exist for exactly this.

Summary

  • Every function’s prototype property is the object that will become the prototype of the objects it produces with new; it is not the function’s own prototype.
  • new creates an empty object whose prototype is bound to the prototype object, runs the constructor on this object, and returns the result; the same job can be written by hand with Object.create.
  • Extending the chain consists of two jobs: binding the sub-prototype to the super-prototype, and calling the super-constructor from inside the sub-constructor.
  • A same-named definition at a lower layer shadows the one above it; polymorphism is the result of this lookup order.
  • instanceof and isPrototypeOf ask not about type membership but whether a prototype object is found on the chain.
  • The link is live: properties added to the prototype afterward become visible on existing objects.

Next Step

In this lesson the chain was set up by hand, and every step of the setup was made visible: the prototype assignment, the constructor repair, the super-constructor call. All three steps being easy to forget is the reason a syntax was added to the language that gathers them into a single declaration. The next lesson introduces class syntax and shows, with the same tests, which operation from this lesson each of its lines corresponds to.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close