Skip to content
JavaScript Basics: Follow Prototype Delegation and Property Shadowing

JavaScript Basics: Follow Prototype Delegation and Property Shadowing

October 22, 2026

A prototype supplies properties that an object does not have itself. This is delegation through a live object link, rather than a one-time copy. Understanding that link explains both reusable methods and surprisingly shared state. These examples create the link explicitly with Object.create() instead of introducing a class hierarchy.

These three independent examples were tested on Linux with Node.js v24.19.0. They use only built-in features, make no network requests, and include assertions. The exact files require Node.js 15.0.0 or later because they import node:assert/strict; the submodule was introduced in Node.js 15. That historical minimum is not a recommendation to use an obsolete release: use a currently supported Node.js version. Only the installed version above was executed for this tutorial.

Prepare a scratch directory

You need Bash, a text editor, Node.js on your PATH, and the mktemp, rm, and rmdir utilities. Keep the same terminal open. Save each named source file as UTF-8 in this directory; .mjs selects an ES module. The guarded commands stop if creating or entering the scratch directory fails.

node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

The tested runtime prints:

v24.19.0

1. Override a default, then reveal it again

Save this as shadow-inherited-default.mjs:

import assert from 'node:assert/strict';

const defaults = { timeoutMs: 1000, retries: 2 };
const upload = Object.create(defaults);
upload.timeoutMs = 250;
assert.equal(Object.getPrototypeOf(upload), defaults);
assert.equal(upload.retries, 2);
assert.deepEqual(Object.keys(upload), ['timeoutMs']);
console.log(`Local timeout: ${upload.timeoutMs}`);

defaults.timeoutMs = 2000;
assert.equal(upload.timeoutMs, 250);
delete upload.timeoutMs;
assert.equal(upload.timeoutMs, 2000);
assert.deepEqual(Object.keys(upload), []);
assert.equal(upload.missing, undefined);
console.log(`Inherited timeout: ${upload.timeoutMs}`);

Run it:

node shadow-inherited-default.mjs

Output:

Local timeout: 250
Inherited timeout: 2000

The first read finds timeoutMs directly on upload; that own property shadows the prototype’s value. Assignment creates this local override because the inherited property is an ordinary writable data property. Deleting the override does not delete the default. The next lookup reaches the changed value on defaults.

Object.keys() lists own enumerable string keys, so inherited retries is absent. A lookup that reaches the end of the chain without finding a property produces undefined. Accessors and non-writable inherited properties have different assignment rules; this example deliberately uses writable data properties. See the ECMAScript property lookup algorithm.

2. Share behavior without copying methods

Save this as delegate-job-methods.mjs:

import assert from 'node:assert/strict';

const jobMethods = {
  describe() { return `${this.name}: ${this.state}`; },
};
function makeJob(name) {
  return Object.assign(Object.create(jobMethods), { name, state: 'queued' });
}
const exportJob = makeJob('Export');
const backupJob = makeJob('Backup');
backupJob.state = 'running';
assert.equal(exportJob.describe, backupJob.describe);
assert.deepEqual(Object.keys(exportJob), ['name', 'state']);
assert.equal(exportJob.describe(), 'Export: queued');
console.log(exportJob.describe());
console.log(backupJob.describe());

jobMethods.describe = function () { return `${this.name} [${this.state}]`; };
assert.equal(exportJob.describe(), 'Export [queued]');
assert.equal(backupJob.describe(), 'Backup [running]');
console.log(exportJob.describe());

Run it:

node delegate-job-methods.mjs

Output:

Export: queued
Backup: running
Export [queued]

Both objects reach the same function through jobMethods. The receiver of exportJob.describe() is still exportJob, so the shared method reads that job’s own state. A method inherited from a prototype does not automatically use the prototype as this.

Replacing the prototype’s method changes subsequent lookups for both jobs. That live connection can be useful, but changing shared behavior unexpectedly makes debugging harder. Define the prototype when constructing the objects and keep its role clear. A factory like this can share a few operations without needing inheritance between classes. MDN’s prototype guide describes the same delegation model.

3. Keep mutable state on each instance

Save this as separate-instance-state.mjs:

import assert from 'node:assert/strict';

const sharedDefaults = { tags: [] };
const first = Object.create(sharedDefaults);
const second = Object.create(sharedDefaults);
first.tags.push('urgent');
assert.equal(first.tags, second.tags);
assert.deepEqual(second.tags, ['urgent']);
console.log(`Shared tags: ${second.tags.join(', ')}`);

const methods = { addTag(tag) { this.tags.push(tag); } };
function makeTask() {
  return Object.assign(Object.create(methods), { tags: [] });
}
const draft = makeTask();
const review = makeTask();
draft.addTag('urgent');
assert.notEqual(draft.tags, review.tags);
assert.deepEqual(review.tags, []);
console.log(`Draft tags: ${draft.tags.length}; review tags: ${review.tags.length}`);

Run it:

node separate-instance-state.mjs

Output:

Shared tags: urgent
Draft tags: 1; review tags: 0

first.tags.push(...) mutates the inherited array. It does not assign a new tags property to first, so the second object observes the same array. This is an easy mistake when moving default values onto a prototype.

The factory gives every task a fresh array while leaving the method shared. Use this pattern for lists, maps, and other mutable per-instance state. Copying an object only shallowly would not separate objects nested inside its arrays. Do not modify Object.prototype to provide application defaults; use a dedicated prototype or ordinary explicit configuration instead.

Clean up

In the same terminal, remove only the named files, return to the original directory, and remove the now-empty scratch directory:

rm -- shadow-inherited-default.mjs delegate-job-methods.mjs separate-instance-state.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on