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.