Module 4 ended with a very concrete problem: every Nómada Tasks task has to know how to describe itself, check whether it is overdue and change its status, but writing those methods inside every object means six hundred tasks would drag along six hundred copies of the same code. What is needed is a shared mold. In almost every language that mold is called a "class" and works by copying a template. In JavaScript the mechanism is different, and simpler than it looks: every object holds a link to another object, and when you ask it for a property it does not have, it looks for it there. That link is called the prototype, and it is the heart of the language. In this lesson you are going to understand it completely: how property access is resolved step by step, what new does exactly —settling the debt we noted down in 04-02—, how an inheritance hierarchy is built by hand, and why the classes you will see in the next lesson are nothing more than an elegant façade over all of this.

Contents

  1. The problem of repeating methods object by object
  2. Every object has a [[Prototype]] link
  3. The prototype chain: how property access is resolved
  4. Reading and writing the prototype: getPrototypeOf, setPrototypeOf, __proto__
  5. Object.create: making an object with the prototype you choose
  6. The prototype property of functions
  7. What new does exactly: the four steps
  8. The Task constructor and its methods on Task.prototype
  9. Why this saves memory
  10. instanceof, constructor and isPrototypeOf
  11. Own properties versus inherited ones
  12. Inheritance between constructors, "by hand"
  13. Object.prototype and why every object has toString
  14. Do not touch the native prototypes
  15. Common Mistakes and Tips
  16. Exercises
  17. Conclusion

  1. The problem of repeating methods object by object

Let us start by putting the problem on the table, with code you already know how to write. This is a direct way of giving a task some behavior: a factory function that returns the object with its methods inside.

'use strict';

const TODAY = '2026-09-20';
const BADGES = { pending: '○', 'in-progress': '▸', done: '✓' };

function createTaskWithMethods(id, title, assignee, estimatedHours, dueDate, status) {
  return {
    id,
    title,
    assignee,
    estimatedHours,
    dueDate,
    status,

    // The methods are created AGAIN on every call to the factory
    isOverdue(today) {
      return this.dueDate < today && this.status !== 'done';
    },
    describe() {
      return `${BADGES[this.status]} [${this.id}] ${this.title} · ${this.assignee} · ${this.estimatedHours} h`;
    }
  };
}

const t1 = createTaskWithMethods(1, 'Redesign the multipurpose room', 'Iván', 12, '2026-09-30', 'in-progress');
const t6 = createTaskWithMethods(6, 'Carpentry workshop quote', 'Iván', 5, '2026-09-05', 'pending');

console.log(t1.describe());        // ▸ [1] Redesign the multipurpose room · Iván · 12 h
console.log(t6.isOverdue(TODAY));  // true

It works perfectly. And yet it has a flaw that shows up in a single line:

console.log(t1.describe === t6.describe);   // false

describe is not the same function in the two tasks. Every call to the factory has created a brand-new function object, with its own space in memory, to do exactly the same thing. With two tasks it makes no difference. With the six hundred the end of the previous module talked about, and four or five methods per task, that is thousands of identical functions taking up memory for nothing.

What we want is this:

console.log(t1.describe === t6.describe);   // true  ← a single, shared function

And to get it you have to understand JavaScript's property lookup mechanism.

  1. Every object has a [[Prototype]] link

Here is the fundamental rule, and everything else follows from it:

Every JavaScript object holds an internal reference to another object (or to null), called [[Prototype]]. When you ask for a property the object does not have, the engine looks for it in that other object. And if it is not there either, in that one's prototype, and so on.

The double brackets in [[Prototype]] are the notation the language specification uses for internal slots: it is not a property you can write with a dot, but a link the engine maintains under the hood. There are official ways of reading and changing it, which you will see in section 4.

A minimal example, with no constructors or anything complicated:

'use strict';

// An object that acts as a "mold": it holds the shared behavior
const taskBehavior = {
  describe() {
    return `[${this.id}] ${this.title}`;
  }
};

// An object whose [[Prototype]] is the one above
const task = Object.create(taskBehavior);
task.id = 1;
task.title = 'Redesign the multipurpose room';

console.log(task.describe());                  // '[1] Redesign the multipurpose room'
console.log(Object.hasOwn(task, 'describe'));  // false  ← it does not have it itself

Look at what just happened: task does not have any describe property, and yet task.describe() works. The engine did not find it on task, followed the link up to taskBehavior, found it there and ran it.

And there is a crucial detail that connects directly with what you learned in 04-02: even though the function lives on the prototype, this is still the object the call was made on. That is exactly the rule of the four call forms: this is whatever is to the left of the dot, not where the function was written. That is why this.id is 1.

const other = Object.create(taskBehavior);
other.id = 6;
other.title = 'Carpentry workshop quote';

console.log(other.describe());                 // '[6] Carpentry workshop quote'
console.log(task.describe === other.describe); // true  ← the very same function!

We have already achieved the goal of section 1: a single function shared by every task.

  1. The prototype chain: how property access is resolved

When you write object.property, the engine follows this algorithm:

  1. Does object have an own property called property? If so, return its value and stop.
  2. If not, take the [[Prototype]] of object. Is it null? Then return undefined and stop.
  3. If it is not null, repeat step 1 on that object.

That sequence of linked objects is the prototype chain. It is a list, not a tree: every object has exactly one prototype, and the chain always ends at null.

flowchart TD
    T["task<br/>{ id: 1, title: '…' }"] -->|"[[Prototype]]"| P["taskBehavior<br/>{ describe() }"]
    P -->|"[[Prototype]]"| O["Object.prototype<br/>{ toString, hasOwnProperty, valueOf … }"]
    O -->|"[[Prototype]]"| N["null<br/>(end of the chain)"]

Let us follow three different lookups on that diagram:

Access Path Result
task.title Found on the first object 'Redesign the multipurpose room'
task.describe Not on task → found on taskBehavior the function
task.toString Not on task → nor on taskBehavior → found on Object.prototype the built-in function
task.assignee On none of them; the chain ends at null undefined

And now the part almost nobody explains: reading and writing are not symmetric. The chain is only walked when reading. When assigning, JavaScript creates (or modifies) an own property on the object to the left, without touching the prototype.

'use strict';

const mold = { priority: 'medium' };
const a = Object.create(mold);
const b = Object.create(mold);

console.log(a.priority, b.priority);          // 'medium' 'medium'  ← both inherit it

a.priority = 'high';                          // does NOT modify the mold

console.log(a.priority);                      // 'high'    ← new own property
console.log(b.priority);                      // 'medium'  ← untouched
console.log(mold.priority);                   // 'medium'  ← untouched
console.log(Object.hasOwn(a, 'priority'));    // true
console.log(Object.hasOwn(b, 'priority'));    // false

This is called shadowing: a's own property covers the inherited one, just as in 03-04 a local variable covered an outer one. It is exactly the behavior we want: every task has its own data and shares the behavior on the prototype.

Watch out for the exception: if the inherited property is an object or an array and you mutate it instead of reassigning it, you really are touching the shared one. a.tags.push('x') creates nothing new: it reads tags from the chain and modifies that array, which everyone sees. That is why prototypes should hold methods, not mutable data.

  1. Reading and writing the prototype: getPrototypeOf, setPrototypeOf, __proto__

There are three ways of touching the [[Prototype]] of an object that already exists.

'use strict';

const mold = { greeting: 'Hello' };
const obj = Object.create(mold);

// READING: the correct way
console.log(Object.getPrototypeOf(obj) === mold);   // true

// WRITING: correct, but discouraged (see below)
const otherMold = { greeting: 'Hi there' };
Object.setPrototypeOf(obj, otherMold);
console.log(obj.greeting);                          // 'Hi there'

// LEGACY: __proto__ does both jobs
console.log(obj.__proto__ === otherMold);           // true
obj.__proto__ = mold;                               // equivalent to setPrototypeOf
console.log(obj.greeting);                          // 'Hello'
Form What it does Use it?
Object.getPrototypeOf(obj) Returns the prototype Yes, it is the standard way of reading it
Object.setPrototypeOf(obj, p) Changes the prototype of an existing object Only if there is no alternative: it is very slow
obj.__proto__ Reads or writes the prototype No in new code: it is legacy, kept only for compatibility
Object.create(p) Creates a new object already with prototype p Yes, it is the recommended way

About the performance of setPrototypeOf: JavaScript engines optimize property access heavily by assuming that the "shape" of an object does not change. Changing the prototype on the fly invalidates all those optimizations for that object and for the code that uses it. The practical rule is: decide the prototype at the moment you create the object and do not change it afterwards.

  1. Object.create: making an object with the prototype you choose

Object.create(proto) creates an empty object whose [[Prototype]] is proto. It also accepts a second argument with property descriptors (you will see them in detail in 05-03).

const withPrototype = Object.create({ a: 1 });
console.log(withPrototype.a);                              // 1

const withoutPrototype = Object.create(null);
console.log(Object.getPrototypeOf(withoutPrototype));      // null
console.log(withoutPrototype.toString);                    // undefined  ← it inherits nothing

That Object.create(null) has a very specific use you already ran into in 04-01: dictionaries. When you indexed the backlog byId, the keys came from data; if some piece of data were called 'toString' or 'constructor', a plain object literal would give you a surprise, because those properties already exist as inherited ones.

const plain = {};
console.log('toString' in plain);           // true   ← inherited, even though the object is empty
console.log(plain.constructor);             // [Function: Object]

const dictionary = Object.create(null);
console.log('toString' in dictionary);      // false  ← genuinely clean

For a key→value map with unpredictable keys, Object.create(null) (or simply a Map, as you saw in 04-05) avoids that whole class of collisions.

  1. The prototype property of functions

Here comes the part that causes the most confusion in the whole topic, and the cause is an unfortunate overlap of names. There are two different things called almost the same:

Name Who has it What it is
[[Prototype]] (read with Object.getPrototypeOf) Every object The link followed when looking up a property
.prototype Only functions (regular ones, not arrows) A plain object that will be used as the [[Prototype]] of the instances that function creates with new

Put in one sentence: Task.prototype is not the prototype of Task; it is the prototype the objects created with new Task(...) will have.

'use strict';

function Task(id, title) {
  this.id = id;
  this.title = title;
}

console.log(typeof Task.prototype);                              // 'object'
console.log(Task.prototype.constructor === Task);                // true

const t = new Task(1, 'Redesign the multipurpose room');

console.log(Object.getPrototypeOf(t) === Task.prototype);        // true  ← this is the key!
console.log(Object.getPrototypeOf(Task) === Function.prototype); // true  (Task is a function)

When you declare a regular function, JavaScript automatically creates an empty prototype object for it, with a single non-enumerable property: constructor, which points back to the function. Arrow functions do not have prototype, and that is why they cannot be used with new; it is another consequence of what you saw in 03-02 and 04-02.

const arrow = () => {};
console.log(arrow.prototype);         // undefined
// new arrow();                       // ✗ TypeError: arrow is not a constructor

  1. What new does exactly: the four steps

Now, at last, we settle the debt from 04-02. When you write new Task(1, 'Redesign…'), the engine does four things in this order:

  1. Creates an empty object.
  2. Links its [[Prototype]] to Task.prototype.
  3. Runs the body of Task with this pointing at that new object, passing it the arguments.
  4. Returns that object, unless the body explicitly returns another object (if it returns a primitive or nothing, that is ignored and the new object is returned).
flowchart TD
    A["new Task(1, 'Redesign…')"] --> B["1 · const obj = {}"]
    B --> C["2 · Object.setPrototypeOf(obj, Task.prototype)"]
    C --> D["3 · Task.call(obj, 1, 'Redesign…')<br/>the body assigns this.id, this.title…"]
    D --> E{"Did the body return<br/>an object?"}
    E -->|"Yes"| F["THAT object is returned"]
    E -->|"No"| G["obj is returned"]

You can check it by writing your own new with tools you already know: Object.create from section 5 and the apply from 04-02.

'use strict';

/** Teaching reimplementation of the new operator. */
function myNew(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
}

function Task(id, title) {
  this.id = id;
  this.title = title;
  this.status = 'pending';        // R5: every task is born pending
}

Task.prototype.describe = function () {
  return `[${this.id}] ${this.title} (${this.status})`;
};

const withNew = new Task(7, 'Service the paper guillotine');
const withMyNew = myNew(Task, 7, 'Service the paper guillotine');

console.log(withNew.describe());                     // [7] Service the paper guillotine (pending)
console.log(withMyNew.describe());                   // [7] Service the paper guillotine (pending)
console.log(withMyNew instanceof Task);              // true

That the two versions give the same result proves new is not magic: it is sugar over Object.create plus a call with this pinned.

By convention, functions meant to be used with new are written with an initial capital (Task, Board, ValidationError). It is not a rule of the language, it is a signal for whoever reads the code. And forgetting the new in strict mode gives an immediate error, which is a stroke of luck:

'use strict';
// const broken = Task(8, 'No new');
// ✗ TypeError: Cannot set properties of undefined (setting 'id')
//   because this is undefined in a plain call

  1. The Task constructor and its methods on Task.prototype

With all the pieces in place, here is the Nómada Tasks model written with a constructor and a prototype. It is the "classic" version of what you will write with class in the next lesson.

'use strict';

const TODAY = '2026-09-20';
const WEIGHTS = { high: 3, medium: 2, low: 1 };
const BADGES = { pending: '○', 'in-progress': '▸', done: '✓' };

/**
 * Constructor for Nómada Tasks tasks.
 * @param {Object} data  fields of the canonical model
 */
function Task(data) {
  this.id = data.id;
  this.title = data.title;
  this.assignee = data.assignee ?? null;          // R8: never an empty string
  this.priority = data.priority ?? 'medium';
  this.status = data.status ?? 'pending';         // R5
  this.tags = data.tags ?? [];
  this.estimatedHours = data.estimatedHours;
  this.dueDate = data.dueDate;
  this.reviewer = data.reviewer ?? null;
}

// ── Shared behavior: it lives ONCE, on the prototype ──

Task.prototype.isOverdue = function (today) {
  return this.dueDate < today && this.status !== 'done';          // R10
};

Task.prototype.effort = function () {
  return (WEIGHTS[this.priority] ?? 0) * this.estimatedHours;
};

Task.prototype.describe = function () {
  const badge = BADGES[this.status] ?? '?';
  return `${badge} [${this.id}] ${this.title} · ${this.assignee ?? 'unassigned'} · ${this.estimatedHours} h`;
};

const task1 = new Task({
  id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván',
  priority: 'high', status: 'in-progress', tags: ['space', 'design'],
  estimatedHours: 12, dueDate: '2026-09-30', reviewer: 'Marta'
});

const task6 = new Task({
  id: 6, title: 'Carpentry workshop quote', assignee: 'Iván',
  priority: 'high', status: 'pending', tags: ['carpentry', 'purchasing'],
  estimatedHours: 5, dueDate: '2026-09-05', reviewer: 'Marta'
});

console.log(task1.describe());              // ▸ [1] Redesign the multipurpose room · Iván · 12 h
console.log(task6.isOverdue(TODAY));        // true   ← the carpentry quote, the usual overdue one
console.log(task1.effort());                // 36     (3 × 12)
console.log(task1.describe === task6.describe);   // true  ← goal achieved

With the full backlog, the canonical numbers still come out:

const backlogData = [
  { id: 1, title: 'Redesign the multipurpose room',        assignee: 'Iván',  priority: 'high',   status: 'in-progress', tags: ['space', 'design'],               estimatedHours: 12, dueDate: '2026-09-30', reviewer: 'Marta' },
  { id: 2, title: 'Signage for the screen-printing workshop', assignee: 'Marta', priority: 'medium', status: 'pending',  tags: ['screen-printing', 'communication'], estimatedHours: 6,  dueDate: '2026-10-15', reviewer: null },
  { id: 3, title: 'Update the bookings website',           assignee: 'Lucía', priority: 'high',   status: 'pending',     tags: ['web', 'bookings'],               estimatedHours: 14, dueDate: '2026-10-02', reviewer: 'Iván' },
  { id: 4, title: 'Screen-printing ink inventory',         assignee: 'Marta', priority: 'low',    status: 'done',        tags: ['screen-printing', 'storeroom'],  estimatedHours: 3,  dueDate: '2026-09-12', reviewer: null },
  { id: 5, title: 'Bookbinding guide for residents',       assignee: 'Iván',  priority: 'medium', status: 'in-progress', tags: ['bookbinding', 'documentation'],  estimatedHours: 8,  dueDate: '2026-11-05', reviewer: 'Lucía' },
  { id: 6, title: 'Carpentry workshop quote',              assignee: 'Iván',  priority: 'high',   status: 'pending',     tags: ['carpentry', 'purchasing'],       estimatedHours: 5,  dueDate: '2026-09-05', reviewer: 'Marta' }
];

const backlog = backlogData.map((d) => new Task(d));

const open = backlog.filter((t) => t.status !== 'done');
console.log(backlog.length);                                              // 6
console.log(backlog.reduce((s, t) => s + t.estimatedHours, 0));           // 48
console.log(open.reduce((s, t) => s + t.estimatedHours, 0));              // 45
console.log(open.filter((t) => t.isOverdue(TODAY)).length);               // 1
console.log(backlog.reduce((s, t) => s + t.effort(), 0));                 // 124

The same 48, 45, 1 and 124 as always —but now every element of the array is a Task, with its own behavior, and map, filter and reduce from 04-05 keep working exactly the same.

  1. Why this saves memory

The difference between putting the methods inside the constructor or on the prototype is easy to see with a diagram.

flowchart TD
    subgraph Bad["Methods inside the constructor"]
        A1["task1<br/>data + isOverdue + effort + describe"]
        A2["task2<br/>data + isOverdue + effort + describe"]
        A3["task3<br/>data + isOverdue + effort + describe"]
    end
    subgraph Good["Methods on the prototype"]
        B1["task1<br/>data only"] --> BP["Task.prototype<br/>isOverdue · effort · describe"]
        B2["task2<br/>data only"] --> BP
        B3["task3<br/>data only"] --> BP
    end

With N tasks and M methods:

Strategy Function objects created With N = 600, M = 3
Methods as own properties (this.describe = function…) N × M 1,800 functions
Methods on the prototype M 3 functions

And there is a second benefit, less often quoted but just as important: if you fix a method, the fix reaches every instance already created, because they all read from the same object.

Task.prototype.describe = function () {
  return `${BADGES[this.status]} #${this.id} ${this.title}`;   // new format
};

console.log(task1.describe());   // ▸ #1 Redesign the multipurpose room
console.log(task6.describe());   // ○ #6 Carpentry workshop quote

Neither task was created again: they simply resolve describe along the chain and find the current version. (This power is also dangerous; section 14 explains where the limit is.)

The cost, to be honest: reading an inherited property forces the engine to walk one more link of the chain. It is an irrelevant difference in practice —engines cache the result— but it explains why ten-level chains are not a good idea.

  1. instanceof, constructor and isPrototypeOf

You already used instanceof in 04-08 without an explanation. Now you can understand exactly what it does: obj instanceof F walks the prototype chain of obj looking for the object F.prototype.

console.log(task1 instanceof Task);        // true   ← Task.prototype is in its chain
console.log(task1 instanceof Object);      // true   ← Object.prototype is too
console.log([] instanceof Array);          // true
console.log([] instanceof Object);         // true
console.log(task1 instanceof Array);       // false

The other two related mechanisms:

// constructor: the property the prototype comes with out of the box
console.log(task1.constructor === Task);         // true
console.log(task1.constructor.name);             // 'Task'

// isPrototypeOf: the question the other way round, from the prototype
console.log(Task.prototype.isPrototypeOf(task1));     // true
console.log(Object.prototype.isPrototypeOf(task1));   // true
Tool Question it answers Warning
obj instanceof F Is F.prototype in the chain of obj? This is what you use in 99 % of cases
obj.constructor Which function is listed as the constructor? It is a plain, overwritable property: not reliable as a security check
P.isPrototypeOf(obj) Is P a link in the chain of obj? Useful when you have the prototype but not the constructor (objects from Object.create)

The warning about constructor is not theoretical. If you replace the whole prototype object instead of adding properties to it, you lose that reference:

function Note(text) { this.text = text; }

Note.prototype = {                    // ✗ the whole object has been replaced
  read() { return this.text; }
};

const n = new Note('hello');
console.log(n.read());                // 'hello'  (it works)
console.log(n.constructor === Note);  // false    ← lost
console.log(n.constructor === Object);// true     ← it now points at the literal

// The fix if you do this: restore it by hand
Note.prototype.constructor = Note;

The safe way is to add methods one by one (Note.prototype.read = …), as in section 8.

  1. Own properties versus inherited ones

This distinction, which you met in 04-01 with Object.hasOwn, now makes complete sense.

console.log(Object.hasOwn(task1, 'title'));       // true   ← own data
console.log(Object.hasOwn(task1, 'describe'));    // false  ← inherited from the prototype
console.log('describe' in task1);                 // true   ← in DOES look at the chain

The difference between in and Object.hasOwn is precisely whether the prototype chain is walked or not:

Expression Does it look at the object? Does it look at the chain?
Object.hasOwn(obj, 'x') Yes No
obj.hasOwnProperty('x') Yes No (but the method itself is inherited; Object.hasOwn is the modern, safe version)
'x' in obj Yes Yes

And now the behavior of for...in that was presented to you in 04-01 as a warning is explained: for...in walks the enumerable properties, own and inherited.

'use strict';

function Point(x) { this.x = x; }
Point.prototype.describe = function () { return `x=${this.x}`; };

const p = new Point(5);

for (const key in p) console.log(key);
// x
// describe        ← the inherited method too!

console.log(Object.keys(p));            // [ 'x' ]  ← own properties only

So why did for...in over the tasks in section 8 not print describe? Because it would. That is exactly the reason why in 04-01 the recommendation was to use Object.keys/values/entries, which only consider own properties. If for some reason you need for...in, the classic protection is to filter:

for (const key in p) {
  if (!Object.hasOwn(p, key)) continue;
  console.log(key);          // only 'x'
}

One note to complete the picture: the properties that come from Object.prototype (toString, valueOf, hasOwnProperty…) do not show up in for...in because they are marked as non-enumerable. The methods you add to a prototype with a normal assignment are enumerable, and that is why they do show up. In 05-03 you will see how to control that with Object.defineProperty, and in the next lesson you will discover that class methods are non-enumerable by default —one of the many quiet improvements they bring.

  1. Inheritance between constructors, "by hand"

Taller Nómada has tasks that come round every week: collecting the screen-printing materials, checking the bookings. A recurring task is a task with everything a normal task has, plus a frequency. It is the textbook case for inheritance: we want RecurringTask to have all the behavior of Task and add its own.

With constructors, inheritance is set up in two steps that you have to do explicitly.

'use strict';

// ── Step 1: inherit the DATA ──────────────────────────────────────
function RecurringTask(data) {
  Task.call(this, data);                // runs the parent constructor on THIS object
  this.frequency = data.frequency ?? 'weekly';
}

// ── Step 2: inherit the BEHAVIOR ──────────────────────────────────
RecurringTask.prototype = Object.create(Task.prototype);
RecurringTask.prototype.constructor = RecurringTask;   // restore the reference

// ── The child's own methods ───────────────────────────────────────
RecurringTask.prototype.nextDate = function () {
  const days = { weekly: 7, fortnightly: 14, monthly: 30 };
  const base = new Date(this.dueDate);
  base.setDate(base.getDate() + (days[this.frequency] ?? 7));
  return base.toISOString().slice(0, 10);
};

// ── Overriding: the child redefines a method of the parent ────────
RecurringTask.prototype.describe = function () {
  const base = Task.prototype.describe.call(this);     // calls the parent's version
  return `${base} · repeats ${this.frequency}`;
};

const tidyUp = new RecurringTask({
  id: 8, title: 'Collect the screen-printing materials', assignee: 'Marta',
  priority: 'low', estimatedHours: 1, dueDate: '2026-09-25',
  frequency: 'weekly'
});

console.log(tidyUp.describe());
// ○ [8] Collect the screen-printing materials · Marta · 1 h · repeats weekly
console.log(tidyUp.nextDate());                    // '2026-10-02'
console.log(tidyUp.isOverdue(TODAY));              // false   ← method inherited from the grandparent
console.log(tidyUp instanceof RecurringTask);      // true
console.log(tidyUp instanceof Task);               // true

The two steps deserve a separate look, because they are the source of almost every mistake in this topic:

  1. Task.call(this, data) is the call from 04-02 put to work. It runs the body of the parent constructor with this pointing at the object being built, so that every assignment (this.id = …, this.title = …) lands on it. Without this line, the recurring task would have methods but no data at all.
  2. Object.create(Task.prototype) creates an empty object linked to the parent's prototype, and sets it as the child's prototype. An extremely common mistake is writing RecurringTask.prototype = Task.prototype (without Object.create): then the two share the same object, and any method you add to the child shows up on the parent too.

Here is the complete chain:

flowchart TD
    L["tidyUp<br/>{ id: 8, title, frequency… }"] -->|"[[Prototype]]"| TR["RecurringTask.prototype<br/>{ nextDate, describe, constructor }"]
    TR -->|"[[Prototype]]"| TP["Task.prototype<br/>{ isOverdue, effort, describe }"]
    TP -->|"[[Prototype]]"| OP["Object.prototype<br/>{ toString, hasOwnProperty… }"]
    OP -->|"[[Prototype]]"| N["null"]

And that diagram explains overriding: when you ask for tidyUp.describe, the engine finds the version on RecurringTask.prototype before it gets to the one on Task.prototype, so it uses the child's. The parent's is still there, covered up, and that is why the child can call it explicitly with Task.prototype.describe.call(this).

Keep this five-line ceremony in mind —call, Object.create, restoring constructor, .call for the parent's method—, because in the next lesson it shrinks to two words: extends and super.

  1. Object.prototype and why every object has toString

At the end of almost every prototype chain sits Object.prototype. That is where methods you have used without wondering where they came from live:

const task = { id: 1, title: 'Redesign the multipurpose room' };

console.log(task.toString());                  // '[object Object]'
console.log(task.hasOwnProperty('id'));        // true
console.log(task.valueOf() === task);          // true
console.log(Object.getPrototypeOf(task) === Object.prototype);   // true

That '[object Object]' that shows up when you concatenate an object with a string —and which in 01-07 we warned you was a bad sign— is literally the toString inherited from Object.prototype. You can give your tasks a better implementation:

Task.prototype.toString = function () {
  return `Task#${this.id} "${this.title}"`;
};

console.log(`Logged: ${task1}`);   // 'Logged: Task#1 "Redesign the multipurpose room"'
console.log(task1 + '');           // 'Task#1 "Redesign the multipurpose room"'

The implicit string conversion from 01-07 consults that method, so now your objects print usefully. Note that this is different from toJSON (04-08), which only takes part in JSON.stringify.

Every built-in type has its own intermediate prototype, all of them linked to Object.prototype:

Value Its chain
[1, 2, 3] Array.prototypeObject.prototypenull
'hello' (when accessing a method) String.prototypeObject.prototypenull
function f(){} Function.prototypeObject.prototypenull
new Map() Map.prototypeObject.prototypenull
Object.create(null) null (no chain)

That answers a question you may have asked yourself in Module 4: why an array has map, filter and reduce. The array does not have them; Array.prototype does, and every array finds them by following its link. The same with 'text'.toUpperCase(): strings are primitives, but when you call a method the engine momentarily wraps them in a String object, which does have the chain.

  1. Do not touch the native prototypes

Now that you have just seen that methods are shared along the chain, the temptation is immediate: "if I add a method to Array.prototype, every array in the program will have it".

// ✗✗✗ DO NOT DO THIS
Array.prototype.last = function () {
  return this[this.length - 1];
};

console.log([1, 2, 3].last());   // 3   (it works… until it stops working)

Modifying a native prototype is called monkey patching, and it is one of the few things the community considers bad practice almost without exception. The reasons:

  • Collisions. If a library adds Array.prototype.last with different behavior, one of the two wins and the other fails silently.
  • Collisions with the future of the language. It has really happened: code that added Array.prototype.flatten broke entire pages when the standard adopted flat. The committee had to change the name for compatibility.
  • It pollutes for...in. As you saw in section 11, a method added this way is enumerable and will show up in any for...in over an array anywhere in the program.
  • It surprises whoever reads the code. A .last() that is not in the JavaScript documentation forces the reader to hunt for where on earth it is defined.

The alternatives are always better:

// ✓ A plain function
function last(array) {
  return array[array.length - 1];
}

// ✓ Or simply what the language already gives you (04-03)
console.log([1, 2, 3].at(-1));     // 3

The complete rule: add methods to the prototypes you create (Task.prototype), never to the ones that are not yours (Array.prototype, Object.prototype, String.prototype…).

Common Mistakes and Tips

  • Confusing prototype with [[Prototype]]. Task.prototype is a property of the function and only matters when creating instances; Object.getPrototypeOf(task) is the real link of the object. The relationship between the two fits in a single line: Object.getPrototypeOf(new Task()) === Task.prototype.
  • Forgetting new. In strict mode you get an immediate TypeError because this is undefined; without strict mode, the assignments land on the global object and quietly pollute everything. One more reason for the 'use strict' you have been carrying since 01-04. The classes in 05-02 make this mistake impossible.
  • Putting mutable data on the prototype. Task.prototype.tags = [] looks harmless, but every task would share that same array, and a push from one of them would change it for all. Data goes in the constructor (own properties); on the prototype, only methods and immutable constants.
  • Replacing the whole prototype object (Task.prototype = { … }) breaks the constructor property and, if you had already created instances, they remain linked to the old object. Add methods one by one, or restore constructor explicitly.
  • Forgetting Parent.call(this, …) when inheriting: the child instance has the methods but no data at all, and everything comes out undefined. It is the number one mistake in manual inheritance.
  • Writing Child.prototype = Parent.prototype instead of Object.create(Parent.prototype): they share the object, and the child's methods leak into the parent.
  • Using Object.setPrototypeOf on the fly. It is correct but slow; decide the prototype when you create the object.
  • Trusting obj.constructor to decide logic. It is a plain property anyone can overwrite. To check the type, use instanceof.
  • Debugging tip: in the browser console, expanding an object shows its own properties and, at the end, a [[Prototype]] entry you can unfold to see the whole chain. It is the fastest way of checking whether inheritance was wired up correctly.

Exercises

Exercise 1 — The chain, on paper. Given this code, say without running it what each console.log prints and at which link of the chain each property is resolved.

const base = { type: 'task', describe() { return `I am a ${this.type}`; } };
const child = Object.create(base);
child.type = 'subtask';
const grandchild = Object.create(child);

console.log(grandchild.type);                       // (a)
console.log(grandchild.describe());                 // (b)
console.log(Object.hasOwn(grandchild, 'type'));     // (c)
grandchild.type = 'microtask';
console.log(child.type, base.type);                 // (d)
console.log(Object.keys(grandchild));               // (e)

Exercise 2 — A Subtask constructor that inherits from Task. Starting from the Task constructor in section 8, write Subtask(data) that:

  • inherits data and behavior from Task;
  • adds a parentId property;
  • overrides describe() so the result is indented by two spaces and ends with ← subtask of #parentId;
  • adds an isLeaf() method that returns true if this.subtasks is empty or does not exist.

Check it with subtask 13 "Paint and assemble" (5 h, pending, belonging to task 1), and verify the two instanceof results.

Exercise 3 — countOwnMethods. Write a function inventory(obj) that returns an object { own, inherited, chain } where own is the number of enumerable own properties, inherited the number of enumerable properties that only appear along the chain, and chain an array with the constructor names of each link (for example ['RecurringTask', 'Task', 'Object']). Try it with the recurring task from section 12.

Solutions

Exercise 1

(a) 'subtask'       → not on grandchild; found on child (child's own property)
(b) 'I am a subtask'
      → describe is resolved on base (two links up),
        but this is grandchild, and this.type is resolved on child → 'subtask'
(c) false           → grandchild has no own type (yet)
(d) 'subtask' 'task'
      → the assignment created an OWN property on grandchild; the chain is untouched
(e) [ 'type' ]      → after the assignment, grandchild does have an own one

Point (b) is the most instructive: the function lives on base, but this is decided at the call (grandchild.describe()), so it points at grandchild; and from there, this.type walks the chain again as far as child. Method and data are resolved on different links, and that is completely normal.

Exercise 2

'use strict';

function Subtask(data) {
  Task.call(this, data);                   // 1 · the parent's data
  this.parentId = data.parentId;
  this.subtasks = data.subtasks ?? [];
}

Subtask.prototype = Object.create(Task.prototype);   // 2 · the parent's behavior
Subtask.prototype.constructor = Subtask;             // 3 · restore constructor

Subtask.prototype.describe = function () {
  const base = Task.prototype.describe.call(this);
  return `  ${base} ← subtask of #${this.parentId}`;
};

Subtask.prototype.isLeaf = function () {
  return !Array.isArray(this.subtasks) || this.subtasks.length === 0;
};

const paint = new Subtask({
  id: 13, title: 'Paint and assemble', assignee: 'Iván',
  priority: 'medium', estimatedHours: 5, dueDate: '2026-09-28',
  parentId: 1
});

console.log(paint.describe());
//   ○ [13] Paint and assemble · Iván · 5 h ← subtask of #1
console.log(paint.isLeaf());              // true
console.log(paint.isOverdue(TODAY));      // false   ← inherited from Task.prototype
console.log(paint.effort());              // 10      (medium = 2 × 5 h)
console.log(paint instanceof Subtask);    // true
console.log(paint instanceof Task);       // true

The three numbered steps are the complete recipe for manual inheritance. If you leave out the first, paint.title would be undefined; if you leave out the second, paint.isOverdue would not exist; if you leave out the third, everything works but paint.constructor would lie and say Task.

Exercise 3

'use strict';

function inventory(obj) {
  const own = Object.keys(obj);
  const all = [];
  for (const key in obj) all.push(key);              // own + inherited enumerable ones

  const chain = [];
  let current = Object.getPrototypeOf(obj);
  while (current !== null) {
    chain.push(current.constructor?.name ?? '(no constructor)');
    current = Object.getPrototypeOf(current);
  }

  return {
    own: own.length,
    inherited: all.length - own.length,
    chain
  };
}

console.log(inventory(tidyUp));
// { own: 10, inherited: 5, chain: [ 'RecurringTask', 'Task', 'Object' ] }

Three things this exercise makes clear. First, Object.keys and for...in differ exactly in the inherited enumerable properties: subtracting one from the other counts them. Second, the while that walks the chain with Object.getPrototypeOf until it reaches null is the standard pattern for inspecting a hierarchy, and its stopping condition (!== null) is the "base case" from 03-07 applied to an iteration. And third, the five inherited ones are the methods we put on the two prototypes with a normal assignment: the ones from Object.prototype do not show up because they are non-enumerable.

Conclusion

You have reached the mechanism that holds up JavaScript's entire object system, and it turns out to be a single idea repeated: every object holds a link to another object, and the properties it cannot find on itself it looks for there. Every consequence you have seen comes out of that idea. The prototype chain is walked when reading, but never when writing, and that is why an assignment always creates an own property that shadows the inherited one. Object.create(proto) is the clean way of making an object with whatever chain you want —including the empty chain of Object.create(null) for dictionaries—, while Object.getPrototypeOf reads it, Object.setPrototypeOf changes it at a high cost, and __proto__ is a legacy you should not write.

You have finally separated the two things with similar names: [[Prototype]], which every object has, and .prototype, which only functions have and which serves one concrete purpose: being the prototype of the instances that function creates with new. And you have taken new apart into its four steps —create the object, link it to prototype, run the body with this pointing at it, return it—, to the point of reimplementing it with Object.create and apply. That third row of the call-forms table from 04-02 has nothing mysterious about it any more.

On that basis you have built the real model: function Task(data) with the nine properties of the canonical model as own data, and isOverdue, effort and describe living just once on Task.prototype. The usual numbers —48 h, 45 open, the overdue carpentry quote, effort 124— come out the same, but now with three functions in memory instead of one thousand eight hundred. You can tell own properties from inherited ones with Object.hasOwn versus in, you finally understand why for...in walks the inherited ones and Object.keys does not, and you can check types with instanceof, isPrototypeOf and —with reservations— constructor. You have set up a complete inheritance by hand with Parent.call(this, …) and Object.create(Parent.prototype), overriding a method and calling the parent's version. And you know why Array.prototype has map, why every object responds to toString, and why you must never add anything to those prototypes that are not yours.

All that ceremony works, but it is noisy: three lines of ritual for every inheritance relationship, a constructor you have to restore by hand, methods declared far from the constructor and left enumerable by accident. Since 2015 the language has offered a syntax that does exactly the same thing —without changing the mechanism one bit— but in a readable, safe way and with the fixes already applied out of the box. That is the subject of Classes and Object-Oriented Programming, where you will rewrite Task, RecurringTask and a complete Board with class, extends and super, and where you will finally, fully understand that class ValidationError extends Error you have been using as a recipe since Module 2.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved