Skip to content
academia.sh

Lesson 04 / 30

Prototype

Creating an object by copying an existing one instead of constructing it: comparing a version that hand-writes derived tariffs' fields against one that clones a prototype, measuring the missing-field count when a field is added to the prototype, counting the child objects a shallow copy shares, and the cost of the derivation chain returning as a silent change.

Contents

The builder constructs every object from nothing. Most objects built in the library are small departures from each other: a contracted customer’s tariff is the standard tariff with two fields changed, a zonal tariff the same with one zone factor changed, a corporate tariff a change to one field of the contracted tariff. Building these step by step means writing the same fields over and over.

The prototype pattern turns creation into copying: a new object is obtained by copying an existing object and changing a few fields on it. This lesson has two measures. The first is the gain: when a field is added to the prototype, how many fields go missing in the derived objects. The second is the cost: how many objects an unwanted change to a shared reference reaches, and how many child objects the copy shares with the prototype.

Hand-Written Derived Tariffs

In the first version, all four tariffs are independent. Each carries its five fields written out in its own text.

// manual/tariff.mjs — every derived tariff rewrites all of its fields
export const standard = {
  name: "standard",
  tiers: [[1, 3900], [5, 6400], [20, 11800]],
  zone: { "34": 100, "06": 115, "65": 140 },
  minimumFee: 3900,
  insuranceRate: 0.004,
};

export const contracted = {
  name: "contracted",
  tiers: [[1, 3900], [5, 6400], [20, 11800]],
  zone: { "34": 100, "06": 115, "65": 140 },
  minimumFee: 3200,
  insuranceRate: 0.003,
};

export const zonal = {
  name: "zonal",
  tiers: [[1, 3900], [5, 6400], [20, 11800]],
  zone: { "34": 100, "06": 115, "65": 125 },
  minimumFee: 3900,
  insuranceRate: 0.004,
};

export const corporate = {
  name: "corporate",
  tiers: [[1, 3900], [5, 6400], [20, 11800]],
  zone: { "34": 100, "06": 115, "65": 140 },
  minimumFee: 3200,
  insuranceRate: 0.002,
};

The tier table is written four times, the zone table three, in identical form. This is the knowledge duplication defined in the Clean Code course: nothing guarantees the four objects’ tier tables stay equal.

Cloning from a Prototype

In the second version, only the standard tariff is written in full. The others come from a derive call: the prototype is cloned, its name and the deriving prototype’s name are written on, and the requested fields are then applied over the top.

// prototype/tariff.mjs — derived tariffs clone the prototype and change fields
export const standard = {
  name: "standard",
  tiers: [[1, 3900], [5, 6400], [20, 11800]],
  zone: { "34": 100, "06": 115, "65": 140 },
  minimumFee: 3900,
  insuranceRate: 0.004,
};

export function derive(prototype, name, changes) {
  const copy = structuredClone(prototype);
  return Object.assign(copy, { name, derivedFrom: prototype.name }, changes);
}

export const contracted = derive(standard, "contracted", { minimumFee: 3200, insuranceRate: 0.003 });
export const zonal = derive(standard, "zonal", { zone: { ...standard.zone, "65": 125 } });
export const corporate = derive(contracted, "corporate", { insuranceRate: 0.002 });

The last line shows what sets the pattern apart: the corporate tariff derives not from the standard tariff but from the contracted tariff. A prototype is not a class but a running object, so a derived object can itself be a prototype. This mechanism has nothing to do with JavaScript’s own [[Prototype]] link, covered in the Objects and Functions in JavaScript course; that link builds a lookup chain, while the clone here is an independent object carrying its fields as copies.

The following script computes the fee for the same shipment under all four tariffs in a given directory. This is how the two versions’ equivalence is checked.

// price.mjs — the fee for the same shipment under the four tariffs in the given directory
const dir = process.argv[2];
const t = await import(`./${dir}/tariff.mjs`);
const S = { weight: 2.4, province: "65", declaredValue: 180000 };

const fee = (tf) => {
  const baseFee = tf.tiers.find(([max]) => S.weight <= max)?.[1] ?? 15000;
  const raw = Math.round((baseFee * (tf.zone[S.province] ?? 160)) / 100) +
    Math.round(S.declaredValue * tf.insuranceRate);
  return Math.max(raw, tf.minimumFee);
};

for (const name of ["standard", "contracted", "zonal", "corporate"])
  console.log(`${dir.padEnd(9)} ${name.padEnd(11)} ${fee(t[name])} cents`);
node price.mjs manual
node price.mjs prototype
manual    standard    9680 cents
manual    contracted  9500 cents
manual    zonal       8720 cents
manual    corporate   9320 cents
prototype standard    9680 cents
prototype contracted  9500 cents
prototype zonal       8720 cents
prototype corporate   9320 cents

All four fees match. The only difference is how the derived objects were obtained.

Shallow Copy Versus Deep Copy

The pattern’s first trap is the copy’s depth. Object spread ({ ...prototype }) copies fields one by one, but when a field points to an object or array, what gets copied is the reference itself. The measurer below counts reference equality for the prototype’s two child objects, then changes a tier value on each copy and reports what happened to the prototype.

// sharing.mjs — child objects the shallow and deep copies share with the prototype
import { standard } from "./prototype/tariff.mjs";

const CHILDREN = ["tiers", "zone"];
const shared = (t) => CHILDREN.filter((c) => t[c] === standard[c]).length;

const shallow = { ...standard, name: "shallow-copy" };
const deep = structuredClone(standard);
deep.name = "deep-copy";

console.log(`shallow shared-child-objects=${shared(shallow)}`);
console.log(`deep    shared-child-objects=${shared(deep)}`);

deep.tiers[0][1] = 4200;
console.log(`deep tier changed -> standard first tier=${standard.tiers[0][1]}`);
shallow.tiers[0][1] = 4200;
console.log(`shallow tier changed -> standard first tier=${standard.tiers[0][1]}`);
node sharing.mjs
shallow shared-child-objects=2
deep    shared-child-objects=0
deep tier changed -> standard first tier=3900
shallow tier changed -> standard first tier=4200

The shallow copy shares both child objects with the prototype. The result shows in the last line: writing to the shallow copy’s tier also changed the prototype’s tier. This is the same common coupling measured in the Types of Coupling lesson — two objects’ behavior tied to one shared mutable state — and it is the most common way the pattern gets applied wrong. In the deep copy, the shared child object count is zero, so the prototype did not change.

Tracking a Field Added to the Prototype

The pattern’s gain shows up when the prototype changes. The measurer below counts, across the three derived tariffs, how many fields stay undefined, lists their insurance rates, and gives the depth of the derivation chain.

// field-count.mjs — missing field, insurance rate and prototype chain of the derived tariffs
const dir = process.argv[2];
const t = await import(`./${dir}/tariff.mjs`);
const FIELDS = ["name", "tiers", "zone", "minimumFee", "insuranceRate", "fuelSurcharge"];
const DERIVED = ["contracted", "zonal", "corporate"];

const missing = DERIVED.reduce(
  (total, name) => total + FIELDS.filter((f) => t[name][f] === undefined).length, 0);
const rate = DERIVED.map((name) => t[name].insuranceRate).join(",");
const chain = (name) => (t[name].derivedFrom === undefined ? 0 : 1 + chain(t[name].derivedFrom));
const depth = Math.max(...DERIVED.map(chain));

console.log(`${dir.padEnd(13)} missing-field=${missing} insurance-rate=${rate} ` +
  `chain-depth=${depth}`);

The change is a single request: add a fuel surcharge field to the standard tariff and raise its insurance rate. The script below applies that edit, in identical form, to copies of both trees; it locates the text by name to make sure it only touches the standard tariff’s field.

// patch.mjs — raises the standard tariff's insurance rate and adds a fuel surcharge field
import { readFileSync, writeFileSync } from "node:fs";

const path = process.argv[2];
const text = readFileSync(path, "utf8");
const start = text.indexOf('name: "standard"');
const field = text.indexOf("insuranceRate:", start);
const lineEnd = text.indexOf("\n", field);
writeFileSync(path,
  `${text.slice(0, field)}insuranceRate: 0.006,\n  fuelSurcharge: 250,${text.slice(lineEnd)}`);
node field-count.mjs manual
node field-count.mjs prototype
cp -r manual manual-new
cp -r prototype prototype-new
node patch.mjs manual-new/tariff.mjs
node patch.mjs prototype-new/tariff.mjs
node field-count.mjs manual-new
node field-count.mjs prototype-new
node price.mjs manual-new
node price.mjs prototype-new
manual        missing-field=3 insurance-rate=0.003,0.004,0.002 chain-depth=0
prototype     missing-field=3 insurance-rate=0.003,0.004,0.002 chain-depth=2
manual-new    missing-field=3 insurance-rate=0.003,0.004,0.002 chain-depth=0
prototype-new missing-field=0 insurance-rate=0.003,0.006,0.002 chain-depth=2
manual-new standard    10040 cents
manual-new contracted  9500 cents
manual-new zonal       8720 cents
manual-new corporate   9320 cents
prototype-new standard    10040 cents
prototype-new contracted  9500 cents
prototype-new zonal       9080 cents
prototype-new corporate   9320 cents

The gain is in the first column. Before the edit, both versions had three missing fields; the fuel surcharge was absent from every tariff. After the edit, the hand-written count stayed at 3: the new field only reached the standard tariff, and the derived ones never saw it. In the cloning version, the count fell to 0, because the derived tariffs are produced from the prototype at load time.

The cost is in the second and third columns. The insurance rate did not change for the contracted and corporate tariffs, already overwritten on each; it had not been overwritten on the zonal tariff, and rose from 0.004 to 0.006. The fee lines show the counterpart: the zonal tariff’s fee stayed at 8720 cents in the hand-written version and rose to 9080 cents in the cloning version. Nobody asked for this change anywhere; only the prototype was touched.

The third column says why this cost is hard to notice. The chain’s depth is 2: the corporate tariff derives from the contracted tariff, which derives from the standard tariff. Finding a field’s final value means walking the chain backward, checking at each step whether it was overwritten. In the hand-written version, this depth is 0: every tariff’s fields live entirely in its own text.

The two numbers are two faces of the same bond: a new field carrying over on its own and an unwanted change carrying over on its own. The pattern is applied where derived objects genuinely should change along with the prototype; where they must change independently, a carried-over change is a defect, not a gain.

Summary

  • The prototype pattern produces a new object not through construction steps but from a copy of an existing object; a prototype is a running object, not a class, so a derived object can itself be a prototype.
  • When a field was added to the prototype, the hand-written version’s missing-field count stayed at 3, and the cloning version’s fell to 0.
  • The same edit moved the zonal tariff’s insurance rate from 0.004 to 0.006 and its fee from 8720 cents to 9080 cents; that change had not been requested.
  • The shallow copy shared 2 child objects with the prototype, and writing to the copy’s tier also changed the prototype’s; the deep copy’s shared child object count is 0.
  • The derivation chain’s depth is 2 in the cloning version and 0 in the hand-written version; a field’s final value can only be found by walking the chain.

Next Step

All four creational mechanisms arranged where the object comes from, but in every one, the object was held by the caller itself. The next question is how many of the object there are. The library has objects meant to exist as a single instance, such as the tariff registry; the shortest way to satisfy that requirement is to build the object at the module level and reach the same instance from everywhere. The next lesson measures that requirement’s cost in testability: how many tests fail when the same test set runs against a single shared instance, whether the result changes when the order changes, and how many names the reset mechanism adds to the surface it exposes.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close