GUIDE · Creational
Prototype
Specify the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype.
At a glance
- INTENT
- Specify the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype.
- PROBLEM
- Building an object from scratch is expensive or needs runtime-only configuration (a template tuned per region/tenant); you'd rather copy a pre-made one and tweak it.
- SAY
- Participants: Prototype (declares clone()), ConcretePrototype (implements deep/shallow copy), Client (calls clone() instead of
new). Pitch: "copy a configured instance instead of constructing one." - SEEN IN
- JavaScript itself — prototypal inheritance,
Object.create(proto);structuredClone();{ ...obj }/[...arr]copies in Redux reducers & React setState;new Map(map),Buffer.from(buf),Request.clone()/Response.clone()in fetch; document/email templates cloned per recipient; Immerproduce(copy-on-write). - VS
- Factory Method creates fresh via
new; Prototype copies an existing instance. Builder assembles step by step; Prototype starts from a finished object. Memento snapshots state to RESTORE the same object; Prototype copies to make a NEW one.
The idea#
Picture a permission slip on the teacher's desk. She fills it in once: the trip, the fee, the line that says bring lunch. She does not retype that form for every kid. She photocopies the finished slip and writes one kid's name on the copy. If Sam scribbles "bus 4" on his copy, the master on the desk still says only "bring lunch." Each copy is a new slip.
A prototype is that master slip. You configure one instance, then you copy it instead of constructing another one from scratch. The copy is a new object. You tweak the copy. The client calls clone, not new, when it wants another one. You copy when the starting point is a finished setup you already paid for: currency, tax, a default line, a footer.
Copying has a catch, and the slip shows it. A real photocopy has its own sticky note. A sloppy copy duplicates the top page and leaves the stapled second page shared, so a mark on that page hits the master too. A see-through cover laid on the original is not a copy at all. Change the fee on the master and the cover shows the new fee, because it was looking through, not holding its own paper.
Why it matters#
The bug this pattern prevents is paying the setup cost over and over, or worse, handing two customers the same sheet. An invoice template for the EU already has euros, a tax rate, a footer, and a support line. Clone it per customer, add that customer's seats to the copy, and the next customer's clone still starts from the master. If the line items were shared, one customer's price edit would change the template and every later invoice.
You already use this without the textbook name. JavaScript objects delegate through a prototype chain, and Object.create sets that link on purpose. structuredClone deep-copies data, including dates, maps, sets, and nested arrays. Spread is the everyday copy-then-tweak in reducers and React state updates. new Map(existingMap), Buffer.from, Request.clone, and Response.clone copy a value you already have. Document and email templates get cloned per recipient. Immer's produce copies on write.
A factory method is the neighbor that builds fresh with new. A prototype starts from an instance that already exists and copies it. A builder assembles a thing step by step from empty parts. A prototype starts from a finished object. A memento snapshots state so you can restore the same object later. A prototype's copy is a new object with its own identity.
A small example#
function permissionSlip(info) { const notes = info.notes.slice(); return { trip: info.trip, fee: info.fee, student: info.student, notes, copy() { return permissionSlip({ trip: this.trip, fee: this.fee, student: this.student, notes: this.notes, }); }, };} const master = permissionSlip({ trip: 'museum', fee: 5, student: '', notes: ['bring lunch'] });const sam = master.copy();sam.student = 'Sam';sam.notes.push('bus 4');const masterStillClean = master.student === '' && master.notes.length === 1;copy runs the slip back through the same setup, and slice gives the copy its own notes. Sam's extra line stays on Sam's paper. masterStillClean is true.
What the code below is doing#
The sheet shows the same idea twice. The second pass is three different ways to "copy," and they do not mean the same thing.
The first shape uses a class so the book names are visible. InvoiceTemplate is both the prototype and the concrete prototype. It holds currency, tax rate, lines, and a footer. addLine pushes onto this instance. total sums cents times quantity, then applies tax. clone builds a new InvoiceTemplate, copies the plain fields, and maps each line into a new object. The class stays intact. The client keeps one configured master per region and clones it per customer. The EU master is euros, tax 0.2, footer "VAT included," and a support line at 10000 cents. inv1 clones it and adds four seats at 2500 cents, so the total is both lines with tax. inv2 clones it and stays at 12000. The master still has one line. Setting inv2's line to one cent leaves the master at 10000. New identity, same class, deep copy of the lines.
The second shape is how you would write it on a normal day, and it is three tools that do not mean the same thing. Object.create is the see-through cover. slowConfig sits on baseConfig, overrides timeoutMs, and inherits retries and describe, so it reads as three tries and 30000 milliseconds. Change the base retries to 5 and the slow config sees 5. That link is live. It is delegation, not a snapshot. structuredClone is the real photocopy of data. A preset with a nested item and a Map is cloned. Pushing an item and editing the map on the copy leaves the preset at one item and source web. The copy's map is still a Map. A function cannot be cloned that way, and cloning the invoice instance drops the class: the result's prototype is Object.prototype. Spread is the sloppy copy. Status can become paid on a new object while items is still the preset's array. Nested data stays shared.
When you talk about it, say this: copy a configured instance instead of constructing one. The prototype declares clone, the concrete prototype does the deep or shallow copy, and the client calls clone instead of new.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Classic shape (classes, so the participant names are visible)#
class InvoiceTemplate { // Prototype + ConcretePrototype constructor({ currency = 'USD', taxRate = 0, lines = [], footer = '' } = {}) { Object.assign(this, { currency, taxRate, lines, footer }); } addLine(sku, cents, qty = 1) { this.lines.push({ sku, cents, qty }); return this; } total() { const sub = this.lines.reduce((s, l) => s + l.cents * l.qty, 0); return Math.round(sub * (1 + this.taxRate)); } clone() { // deep-copy mutable parts, keep the class return new InvoiceTemplate({ currency: this.currency, taxRate: this.taxRate, lines: this.lines.map((l) => ({ ...l })), footer: this.footer, }); }} // Client: one configured prototype per region, cloned per customerconst euTemplate = new InvoiceTemplate({ currency: 'EUR', taxRate: 0.2, footer: 'VAT included' }) .addLine('SUPPORT', 10000);const inv1 = euTemplate.clone().addLine('SEAT', 2500, 4);const inv2 = euTemplate.clone(); inv2.lines[0].cents = 1; // deep copy: clone's line is not the template'sVerified by 6 asserts
assert.ok(inv1 instanceof InvoiceTemplate); // class preservedassert.notEqual(inv1, euTemplate); // different identityassert.equal(inv1.total(), Math.round((10000 + 10000) * 1.2));assert.equal(inv2.total(), 12000); // prototype untouched by inv1's extra lineassert.equal(euTemplate.lines.length, 1);assert.equal(euTemplate.lines[0].cents, 10000);Idiomatic JS shape: Object.create, structuredClone, spread#
// 1) Object.create — the new object DELEGATES to the prototype (shares behavior, overrides data)const baseConfig = { retries: 3, timeoutMs: 5000, describe() { return `${this.retries}x/${this.timeoutMs}ms`; },};const slowConfig = Object.create(baseConfig, { timeoutMs: { value: 30000, enumerable: true } });baseConfig.retries = 5; // 2) structuredClone — real deep copy of DATA (Date, Map, Set, nested arrays); drops functions/prototypesconst orderPreset = { status: 'draft', items: [{ sku: 'A', qty: 1 }], meta: new Map([['src', 'web']]) };const order = structuredClone(orderPreset);order.items.push({ sku: 'B', qty: 2 });order.meta.set('src', 'api'); // 3) Spread — SHALLOW copy; the everyday "clone then tweak" in reducersconst shallow = { ...orderPreset, status: 'paid' };Verified by 10 asserts
assert.equal(slowConfig.describe(), '3x/30000ms'); // own timeoutMs, inherited retries + describeassert.equal(Object.getPrototypeOf(slowConfig), baseConfig);assert.equal(slowConfig.retries, 5); // live link, not a copy — know which one you wantassert.equal(orderPreset.items.length, 1);assert.equal(orderPreset.meta.get('src'), 'web');assert.ok(order.meta instanceof Map);assert.throws(() => structuredClone({ fn() {} }), /could not be cloned/);assert.equal(Object.getPrototypeOf(structuredClone(euTemplate)), Object.prototype); // class lost!assert.equal(shallow.status, 'paid');assert.equal(shallow.items, orderPreset.items); // nested array is shared — classic footgunSee also