← Design patterns
Creation Existing instances and deliberate copies

Prototype

Start from something that already exists.

An incident card already has the time window and filters someone chose. When they ask for another card like it, starting from that configured instance can preserve choices that no preset captures.

TypeScriptGoRust One copy contract in each language.

Definition

The Prototype pattern creates new objects by copying an existing instance, the prototype, instead of naming a class and building one from scratch.

After Gamma et al., Design Patterns (1994), Prototype

01Start from the configured card

Start with the card someone already configured.

Creating the first incident card from a preset is simple. A constructor or a factory with defaults is a fair fit while every card starts the same way, and at first the Duplicate menu item simply makes another card from the preset the source began as:

duplicate.ts
// Duplicate, before the card could copy itself: a new card from its preset.
function duplicateCard(source: IncidentCard, newId: string): IncidentCard {
  return createCard(source.preset, newId);
}

The preset factory is the one from the Factory lesson: it knows each preset’s window and filters. Users can later change the query window and filters, then choose Duplicate. The edited card may no longer match any preset. Reconstructing it from the preset loses those choices; copying fields in the menu handler makes that handler depend on the card’s internals.

Prototype creates a new object from an existing instance through a duplication operation owned by that instance. The caller chooses the starting instance and provides the new card ID. The card decides what travels into the duplicate.

02Let the card copy itself

“Copy” needs a contract.

The card owns duplicate. It copies the query the new card will edit, shares the definition nobody edits, takes the caller’s new ID, and starts without a preview, which described the source’s last run. This is the whole program the lab runs: the card, then the editor duplicating the configured card into A, editing A to a 30-minute latency query, and duplicating A into B.

card.ts
type Query = { minutes: number; filters: string[] };
type Definition = Readonly<{ name: string; version: number }>;

class IncidentCard {
  id: string;
  query: Query;
  definition: Definition;
  preview: number | null;

  constructor(
    id: string,
    query: Query,
    definition: Definition,
    preview: number | null,
  ) {
    this.id = id;
    this.query = query;
    this.definition = definition;
    this.preview = preview;
  }

  // The prototype: the card decides what its duplicate owns and shares.
  duplicate(newId: string): IncidentCard {
    const query = {
      minutes: this.query.minutes,
      filters: [...this.query.filters],
    };
    return new IncidentCard(newId, query, this.definition, null);
  }

  edit(minutes: number, firstFilter: string): void {
    this.query.minutes = minutes;
    this.query.filters[0] = firstFilter;
    this.preview = null;
  }
}

const definition = Object.freeze({ name: 'Incident feed', version: 1 });
const query = { minutes: 15, filters: ['http-5xx', 'timeout'] };
const source = new IncidentCard('preset-api', query, definition, 42);

// Duplicate the configured card, edit the copy, then copy the copy.
const a = source.duplicate('card-a');
a.edit(30, 'latency');
const b = a.duplicate('card-b');
const sharedDefinition = a.definition === source.definition;

function show(card: IncidentCard) {
  const filters = card.query.filters.join(',');
  const preview = card.preview ?? '-';
  console.log(`${card.id}: ${card.query.minutes}m ${filters} preview=${preview}`);
}
show(source);
show(a);
show(b);
const about = `${definition.name} v${definition.version}`;
console.log(`definition: ${about}, shared=${sharedDefinition}`);
What the incident-card duplicate owns or shares
PartDuplicate receivesReason
Query: window and filtersCurrent values; editable query storage is independent.Later edits belong to one card.
Data definitionThe same read-only definition.There is no card operation that edits it.
Card identityA new ID supplied by the caller.This action creates another entity.
Previous previewEmpty.The old result belongs to the source card.

B starts with A’s current values; the original preset remains at 15 minutes. An edited copy can itself be the prototype for the next one.

What a production prototype addsIdentity, validated edits and views

The new ID must differ from its immediate source. The operation cannot discover unrelated cards, so the caller owns uniqueness across the whole dashboard. IDs and filter codes use 1–24 lowercase ASCII letters, digits, or hyphens, beginning with a letter.

An edit accepts a whole number of minutes from 1 to 240 and a valid first-filter code. Both checks finish before mutation. A rejected edit keeps every value and the prior preview. A successful edit preserves the other filters and clears only that card’s preview.

The card’s state is private, and views copy the editable query too, so inspection does not expose another mutation path. The definition contains only a name and version and has no mutator in this model. Persistence, remote queries, authorization, and concurrent edits are outside this in-memory example.

card.ts
export type Query = { minutes: number; filters: string[] };
export type Definition = Readonly<{ name: string; version: number }>;
export type CardView = Readonly<{
  id: string;
  query: Query;
  definition: Definition;
  preview: number | null;
}>;

// An ID or filter code: a lowercase letter, then up to 23 of a-z, 0-9 and -.
const validCode = (value: string) => /^[a-z][a-z0-9-]{0,23}$/.test(value);
const copyQuery = (query: Query): Query => ({
  minutes: query.minutes,
  filters: [...query.filters],
});

export class IncidentCard {
  #id: string;
  #query: Query;
  #definition: Definition;
  #preview: number | null;

  private constructor(
    id: string,
    query: Query,
    definition: Definition,
    preview: number | null,
  ) {
    this.#id = id;
    this.#query = query;
    this.#definition = definition;
    this.#preview = preview;
  }

  // Two configured cards that share one frozen definition.
  static presets(): Record<'api' | 'worker', IncidentCard> {
    const definition = Object.freeze({ name: 'Incident feed', version: 1 });
    return {
      api: new IncidentCard(
        'preset-api',
        { minutes: 15, filters: ['http-5xx', 'timeout'] },
        definition,
        42,
      ),
      worker: new IncidentCard(
        'preset-worker',
        { minutes: 60, filters: ['queue-lag', 'timeout'] },
        definition,
        7,
      ),
    };
  }

  // The caller supplies the new ID; uniqueness beyond this card is theirs.
  duplicate(newId: string): IncidentCard {
    if (!validCode(newId)) throw new Error(`invalid id: ${newId}`);
    if (newId === this.#id) throw new Error(`same id: ${newId}`);
    const query = copyQuery(this.#query);
    return new IncidentCard(newId, query, this.#definition, null);
  }

  // Validate the whole edit before changing anything.
  edit(minutes: number, firstFilter: string): void {
    if (!Number.isInteger(minutes) || minutes < 1 || minutes > 240) {
      throw new Error(`invalid window: ${minutes}`);
    }
    if (!validCode(firstFilter)) {
      throw new Error(`invalid filter: ${firstFilter}`);
    }
    this.#query.minutes = minutes;
    this.#query.filters[0] = firstFilter;
    this.#preview = null;
  }

  // A copy of the query, so a view is not another way to edit the card.
  view(): CardView {
    return {
      id: this.#id,
      query: copyQuery(this.#query),
      definition: this.#definition,
      preview: this.#preview,
    };
  }

  sharesDefinition(other: IncidentCard): boolean {
    return this.#definition === other.#definition;
  }
}

03Predict what a copy shares

Change how duplicate copies. What happens?

Experiment

Duplicate a configured card

card.ts
// Lines 24–27 become a spread of the query:
const query = { ...this.query };
Suppose duplicate copied the query with a spread, as above. The editor duplicates the source into A, then runs a.edit(30, "latency"). What does the source card show?
Reading the TypeScriptOne-level copies, readonly and freezing

duplicate builds a new query object and a new filters array with [...filters]. That is enough because the filters are strings; an array of objects that are edited in place needs each object copied too. A spread, of an object or an array, copies one level: whatever the copied values point at stays shared.

The definition is a frozen object of primitive values, so sharing that reference is deliberate. Readonly is a type-checker restriction, not runtime freezing; Object.freeze also stops changes at run time, but it is shallow too. The production card keeps its state in #private fields, so the only way to a copy is duplicate.

04Meet it in your language

Every copy API draws its own line.

structuredClone, in browsers and in Node since 17, is the platform’s deep copy, and teams reach for it to duplicate state for an undo stack or a draft. It copies data, not behaviour: a class instance comes back as a plain object holding its own enumerable fields, with no methods and no #private fields, so a card that keeps its state private comes back as {}. A function anywhere inside throws DataCloneError. TypeScript types the result as the class, so the call compiles.

clone.ts
// A card that keeps its state private, as the production card does.
class IncidentCard {
  #id: string;
  #filters: string[];

  constructor(id: string, filters: string[]) {
    this.#id = id;
    this.#filters = filters;
  }

  duplicate(newId: string): IncidentCard {
    return new IncidentCard(newId, [...this.#filters]);
  }

  describe(): string {
    return `${this.#id}: ${this.#filters.join(',')}`;
  }
}

// Before: the platform's deep copy. structuredClone copies own data into a
// plain object, so the copy has no class, no methods and no #fields. Its
// type still says IncidentCard: copy.describe() compiles, then throws.
function copyCardBefore(card: IncidentCard): IncidentCard {
  return structuredClone(card);
}

// After: the card copies itself, by its own rules.
function copyCardAfter(card: IncidentCard, newId: string): IncidentCard {
  return card.duplicate(newId);
}

The fix is the pattern: let the object that owns the state copy itself. Keep structuredClone for plain data, such as a response you are about to post to a worker, and let a class decide what its copies own.

Copies you already make, and where each draws its line
The callWhat is newWhat is still shared
{ ...object } and [...array]A new outer object or array.Everything nested inside it.
structuredClone(value)Plain data, all the way down.Nothing; it drops the class, methods and #private fields, and throws on a function.
Object.create(proto)Nothing: a new, empty object.proto, as the place property lookups go next.
React · cloneElement(element, props)An element description, props merged shallowly.Prop values; no component state is copied.

05Decide when it helps

Use it when existing state is the useful starting point.

A literal or a small copy helper is easier to maintain while one caller copies a simple shape. A constructor or factory fits when new cards start from known defaults. Prototype earns its place when callers need the current configured state and the instance can keep the copy rule beside that state.

What would you have to change tomorrow?
The changeEach caller copies the cardThe card owns duplicate
The card gains another editable field, such as notesEvery caller that copies cards must learn to copy it.Add it to duplicate: one place decides what a copy owns.
A second kind of card, a chart, can be duplicated tooEach caller that copies grows a branch for each kind.Each kind brings its own duplicate; callers ask any card for a copy.
Cards only ever start from presetsA factory is enough: no configured state needs keeping.duplicate is machinery nobody uses.

The rule, field by field: the copy owns what it will edit, shares what nobody edits or someone else owns, gets a new identity from its caller, and starts without what described the source’s history.

Copying costs work proportional to what must become independent. The card copies its query and filters but shares its read-only definition. A larger graph may contain intentional aliases, cycles, IDs, or resources; the copy policy must say which links remain shared, which get new identities, and which require a lifecycle operation. A generic deep-copy utility cannot decide those product rules for you.

A Builder assembles a result as parts arrive; Prototype starts from a result that already exists. A factory can choose a prototype and ask it to duplicate. These patterns can cooperate without becoming interchangeable.

Limits of this card modelResources, persistence, and concurrent edits

This is an in-memory model with one concrete card type and an acyclic query. It does not allocate globally unique IDs, persist records, copy live connections or locks, or coordinate concurrent edits. Those systems need explicit owners and policies.

A heterogeneous collection may benefit from a common duplication protocol so the caller does not know concrete constructors. One card type does not need a hierarchy just to support this operation.

06Make the call

A copy, or a second view of the same thing?

An analyst wants a second panel showing the same live incident query. Editing a filter in either panel should update both. Which design fits that request?
A slide editor’s Duplicate copies a slide holding a table and a chart that reads from that table. Someone then edits a number in the copied table. What should the copied chart show?

Before you open the answer below, say it in your own words: what does the card decide that its caller does not, and when should a second panel share a model instead of getting a duplicate?

One way to say itWho decides the copy, and when not to copy

The caller picks the starting instance and supplies the new ID; the instance decides what its copy gets, because it is the one that knows its fields. Field by field: the copy owns what it will edit, shares what nobody edits or someone else owns, gets its own identity, and starts without what described the source’s history. A careless copy gets this wrong silently: a spread copies one level, and whatever it points at stays shared. Code that copies through duplicate never needs to know which kind of card it holds. When the copied things point at each other, like a chart and its table, links inside the copy follow it and links outside stay put. And when two panels should show the same live query, that is not a duplicate at all: share one model, on purpose, and show that the panels are linked.

Practice in the lab

One step from the lesson’s incident cards: the same team’s on-call tool, where an alert rule pages the team’s channel when a metric crosses a threshold. A tuned rule is a configured instance like the edited card, and the team wants to copy it rather than start again from a template. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • PROTOTYPE-101 (easy) Duplicate forgets how the rule was tuned
  • PROTOTYPE-102 (medium) Copy a whole service’s rules, whatever kind they are Pro
  • PROTOTYPE-103 (hard) Review: copying a service relinks its inhibitions Pro