Episode 9 — System Design / 9.3 — Creational Design Patterns

9.3.e Prototype Pattern

The Problem It Solves

Sometimes creating an object from scratch is expensive or complex:

  • Fetching configuration from a database or API during construction.
  • Running heavy computations to initialize default state.
  • Setting up objects that require many steps (think: a fully configured server, a game character with loaded assets).

If you need many similar objects, re-doing all that work each time is wasteful. The Prototype pattern says: create one object, then clone it.

BEFORE: Create each from scratch
  User A = load from DB + compute defaults + validate  (200ms)
  User B = load from DB + compute defaults + validate  (200ms)
  User C = load from DB + compute defaults + validate  (200ms)
  Total: 600ms

AFTER: Clone a prototype
  Template = load from DB + compute defaults + validate  (200ms)
  User A = clone(Template) + tweak name  (1ms)
  User B = clone(Template) + tweak name  (1ms)
  User C = clone(Template) + tweak name  (1ms)
  Total: 203ms

UML Diagram

+-------------------+
|    Prototype      |
|-------------------|
| + clone(): Self   |
+-------------------+
        ^
        |
+-------------------+         +-------------------+
| ConcretePrototypeA|         | ConcretePrototypeB|
|-------------------|         |-------------------|
| - field1          |         | - field1          |
| - field2          |         | - field2          |
| + clone(): Self   |         | + clone(): Self   |
+-------------------+         +-------------------+

  Client --> prototype.clone() --> new independent copy

Shallow vs Deep Copy

This distinction is critical. Getting it wrong causes the most common Prototype bugs.

// ============ SHALLOW COPY ============
// Copies top-level properties.
// Nested objects are SHARED (same reference).

const original = {
  name: 'Alice',
  scores: [90, 85, 92],
  address: { city: 'NYC', zip: '10001' },
};

const shallowCopy = { ...original };

// Top-level is independent
shallowCopy.name = 'Bob';
console.log(original.name);  // 'Alice' (unaffected)

// Nested objects are SHARED
shallowCopy.scores.push(100);
console.log(original.scores); // [90, 85, 92, 100] <-- BUG! Modified original!

shallowCopy.address.city = 'LA';
console.log(original.address.city); // 'LA' <-- BUG! Modified original!


// ============ DEEP COPY ============
// Copies EVERYTHING recursively.
// Nested objects are independent.

const deepCopy = structuredClone(original);

deepCopy.scores.push(200);
console.log(original.scores); // [90, 85, 92, 100] (unaffected by deep copy)

deepCopy.address.city = 'Chicago';
console.log(original.address.city); // 'LA' (unaffected by deep copy)

Comparison Table

+-------------------+------------------------------+------------------------------+
|                   | Shallow Copy                 | Deep Copy                    |
+-------------------+------------------------------+------------------------------+
| Primitives        | Independent copy             | Independent copy             |
| Nested objects    | SHARED reference             | Independent copy             |
| Arrays            | SHARED reference             | Independent copy             |
| Performance       | Fast (O(n) top-level props)  | Slower (recursive traversal) |
| Methods           | {...obj}, Object.assign()    | structuredClone(), JSON,     |
|                   |                              | custom clone()               |
| Safe for mutation | Only top-level               | Everything                   |
+-------------------+------------------------------+------------------------------+

JavaScript Cloning Methods

Method 1: Spread / Object.assign (Shallow)

const original = { name: 'Template', config: { debug: true } };

const copy1 = { ...original };              // Spread
const copy2 = Object.assign({}, original);  // Object.assign

// Both are shallow -- config is shared!
copy1.config.debug = false;
console.log(original.config.debug); // false (mutation leaked!)

Method 2: JSON Round-Trip (Deep, with limitations)

const original = {
  name: 'Template',
  config: { debug: true, retries: 3 },
  createdAt: new Date(),
  pattern: /test/gi,
  callback: () => 'hello',
};

const copy = JSON.parse(JSON.stringify(original));

console.log(copy.name);        // 'Template' (works)
console.log(copy.config);      // { debug: true, retries: 3 } (works, independent)
console.log(copy.createdAt);   // "2025-01-01T00:00:00.000Z" (string, NOT a Date!)
console.log(copy.pattern);     // {} (RegExp lost!)
console.log(copy.callback);    // undefined (functions lost!)

Limitations of JSON round-trip:

  • Loses Date objects (becomes strings)
  • Loses RegExp, Map, Set, undefined
  • Loses functions and class prototypes
  • Cannot handle circular references

Method 3: structuredClone (Deep, Modern)

// Available in Node.js 17+ and all modern browsers
const original = {
  name: 'Template',
  config: { debug: true },
  createdAt: new Date(),
  data: new Map([['key', 'value']]),
  buffer: new ArrayBuffer(8),
};

const copy = structuredClone(original);

copy.config.debug = false;
console.log(original.config.debug); // true (independent!)

copy.data.set('key', 'modified');
console.log(original.data.get('key')); // 'value' (independent!)

console.log(copy.createdAt instanceof Date); // true (preserved!)

structuredClone handles: Date, RegExp, Map, Set, ArrayBuffer, Blob, circular references. structuredClone does NOT handle: Functions, DOM nodes, class prototypes/methods.

Method 4: Object.create (Prototypal Inheritance, NOT cloning)

// Object.create sets up PROTOTYPAL inheritance -- it does NOT clone.
const proto = {
  greet() { return `Hello, I'm ${this.name}`; },
  type: 'user',
};

const obj = Object.create(proto);
obj.name = 'Alice';

console.log(obj.greet());    // "Hello, I'm Alice"
console.log(obj.type);       // "user" (inherited from proto)
console.log(obj.hasOwnProperty('name')); // true
console.log(obj.hasOwnProperty('type')); // false (it's on the prototype)

// This is NOT a copy -- changing proto affects obj
proto.type = 'admin';
console.log(obj.type); // "admin" (inherited change)

Object.create vs clone:

  • Object.create shares behavior through the prototype chain.
  • Cloning creates a fully independent copy.
  • Use Object.create when you want shared behavior; use cloning when you want independent state.

Implementation 1: Configuration Template Prototype

class ServerConfig {
  constructor(options = {}) {
    this.host = options.host || 'localhost';
    this.port = options.port || 3000;
    this.database = options.database || {
      host: 'localhost',
      port: 5432,
      name: 'myapp',
      pool: { min: 2, max: 10 },
    };
    this.cache = options.cache || {
      enabled: true,
      ttl: 3600,
      maxSize: 1000,
    };
    this.logging = options.logging || {
      level: 'info',
      format: 'json',
      destinations: ['stdout'],
    };
    this.features = options.features || {
      rateLimit: true,
      cors: true,
      compression: true,
    };
  }

  clone() {
    // Deep clone using structuredClone, then create a new instance
    const clonedOptions = structuredClone({
      host: this.host,
      port: this.port,
      database: this.database,
      cache: this.cache,
      logging: this.logging,
      features: this.features,
    });
    return new ServerConfig(clonedOptions);
  }

  describe() {
    return `${this.host}:${this.port} | DB: ${this.database.name} | ` +
           `Log: ${this.logging.level} | Cache: ${this.cache.enabled}`;
  }
}

// --- Create a base template (expensive -- imagine this loads from a file/API) ---
const productionTemplate = new ServerConfig({
  host: '0.0.0.0',
  port: 8080,
  database: {
    host: 'db.production.internal',
    port: 5432,
    name: 'myapp_prod',
    pool: { min: 10, max: 50 },
  },
  cache: { enabled: true, ttl: 7200, maxSize: 10000 },
  logging: { level: 'warn', format: 'json', destinations: ['stdout', 'datadog'] },
  features: { rateLimit: true, cors: false, compression: true },
});

// --- Clone and customize for different services ---
const apiServerConfig = productionTemplate.clone();
apiServerConfig.port = 8081;
apiServerConfig.logging.level = 'info'; // More verbose for API

const workerConfig = productionTemplate.clone();
workerConfig.port = 8082;
workerConfig.cache.enabled = false;     // Workers don't need cache
workerConfig.features.cors = false;
workerConfig.features.rateLimit = false; // Internal service

console.log('Production:', productionTemplate.describe());
// Production: 0.0.0.0:8080 | DB: myapp_prod | Log: warn | Cache: true

console.log('API Server:', apiServerConfig.describe());
// API Server: 0.0.0.0:8081 | DB: myapp_prod | Log: info | Cache: true

console.log('Worker:', workerConfig.describe());
// Worker: 0.0.0.0:8082 | DB: myapp_prod | Log: warn | Cache: false

// Verify independence
console.log(productionTemplate.logging.level); // 'warn' (unchanged!)
console.log(productionTemplate.cache.enabled); // true (unchanged!)

Implementation 2: Prototype Registry

A registry stores named prototypes that can be cloned on demand:

class PrototypeRegistry {
  constructor() {
    this.prototypes = new Map();
  }

  register(name, prototype) {
    if (typeof prototype.clone !== 'function') {
      throw new Error(`Prototype "${name}" must have a clone() method`);
    }
    this.prototypes.set(name, prototype);
  }

  create(name, overrides = {}) {
    const proto = this.prototypes.get(name);
    if (!proto) {
      throw new Error(`No prototype registered as "${name}"`);
    }

    const clone = proto.clone();

    // Apply overrides
    for (const [key, value] of Object.entries(overrides)) {
      if (typeof value === 'object' && value !== null && typeof clone[key] === 'object') {
        Object.assign(clone[key], value); // Merge nested
      } else {
        clone[key] = value;
      }
    }

    return clone;
  }

  list() {
    return [...this.prototypes.keys()];
  }
}

// --- Document templates ---
class Document {
  constructor(options = {}) {
    this.title = options.title || 'Untitled';
    this.content = options.content || '';
    this.metadata = options.metadata || {
      author: 'Unknown',
      version: '1.0',
      format: 'markdown',
      tags: [],
    };
    this.permissions = options.permissions || {
      public: false,
      editable: true,
      shareable: false,
    };
  }

  clone() {
    return new Document(structuredClone({
      title: this.title,
      content: this.content,
      metadata: this.metadata,
      permissions: this.permissions,
    }));
  }

  describe() {
    return `"${this.title}" by ${this.metadata.author} (v${this.metadata.version})`;
  }
}

// --- Setup registry with templates ---
const registry = new PrototypeRegistry();

registry.register('blog-post', new Document({
  title: 'Blog Post Template',
  content: '# Title\n\n## Introduction\n\n## Body\n\n## Conclusion',
  metadata: { author: 'Blog Team', version: '1.0', format: 'markdown', tags: ['blog'] },
  permissions: { public: true, editable: true, shareable: true },
}));

registry.register('internal-doc', new Document({
  title: 'Internal Document',
  content: '# [CONFIDENTIAL]\n\n## Summary\n\n## Details',
  metadata: { author: 'Engineering', version: '1.0', format: 'markdown', tags: ['internal'] },
  permissions: { public: false, editable: true, shareable: false },
}));

registry.register('api-spec', new Document({
  title: 'API Specification',
  content: '# API Spec\n\n## Endpoints\n\n## Authentication\n\n## Rate Limits',
  metadata: { author: 'Platform Team', version: '2.0', format: 'openapi', tags: ['api', 'spec'] },
  permissions: { public: true, editable: false, shareable: true },
}));

// --- Usage: Clone templates with overrides ---
const myBlogPost = registry.create('blog-post', {
  title: 'Understanding Prototypes in JavaScript',
  metadata: { author: 'Alice Chen', tags: ['blog', 'javascript', 'patterns'] },
});
console.log(myBlogPost.describe());
// "Understanding Prototypes in JavaScript" by Alice Chen (v1.0)

const myInternalDoc = registry.create('internal-doc', {
  title: 'Q4 Architecture Review',
  metadata: { author: 'Bob Smith' },
});
console.log(myInternalDoc.describe());
// "Q4 Architecture Review" by Bob Smith (v1.0)

console.log('Available templates:', registry.list());
// Available templates: ['blog-post', 'internal-doc', 'api-spec']

Implementation 3: Game Entity Prototype

class GameEntity {
  constructor(options = {}) {
    this.type = options.type || 'generic';
    this.name = options.name || 'Entity';
    this.hp = options.hp || 100;
    this.maxHp = options.maxHp || 100;
    this.attack = options.attack || 10;
    this.defense = options.defense || 5;
    this.position = options.position || { x: 0, y: 0 };
    this.inventory = options.inventory || [];
    this.abilities = options.abilities || [];
    this.sprite = options.sprite || null; // Imagine this is an expensive loaded asset
  }

  clone() {
    const cloned = new GameEntity(structuredClone({
      type: this.type,
      name: this.name,
      hp: this.hp,
      maxHp: this.maxHp,
      attack: this.attack,
      defense: this.defense,
      position: this.position,
      inventory: this.inventory,
      abilities: this.abilities,
    }));
    // Share the sprite reference (expensive asset, read-only)
    cloned.sprite = this.sprite;
    return cloned;
  }

  describe() {
    return `${this.name} [${this.type}] HP:${this.hp}/${this.maxHp} ` +
           `ATK:${this.attack} DEF:${this.defense} @ (${this.position.x},${this.position.y})`;
  }
}

// --- Expensive prototype creation (imagine loading sprites, sounds, etc.) ---
const goblinPrototype = new GameEntity({
  type: 'enemy',
  name: 'Goblin',
  hp: 50,
  maxHp: 50,
  attack: 8,
  defense: 3,
  abilities: ['bite', 'scratch'],
  sprite: { id: 'goblin_sprite', frames: 24, loaded: true }, // "Expensive" asset
});

// --- Spawn 5 goblins by cloning (fast, sprite is shared) ---
const goblins = [];
for (let i = 0; i < 5; i++) {
  const goblin = goblinPrototype.clone();
  goblin.name = `Goblin_${i + 1}`;
  goblin.position = { x: Math.random() * 100, y: Math.random() * 100 };
  goblins.push(goblin);
}

goblins.forEach((g) => console.log(g.describe()));
/*
Goblin_1 [enemy] HP:50/50 ATK:8 DEF:3 @ (42.1,87.3)
Goblin_2 [enemy] HP:50/50 ATK:8 DEF:3 @ (15.7,63.2)
Goblin_3 [enemy] HP:50/50 ATK:8 DEF:3 @ (91.4,22.8)
Goblin_4 [enemy] HP:50/50 ATK:8 DEF:3 @ (58.9,44.1)
Goblin_5 [enemy] HP:50/50 ATK:8 DEF:3 @ (73.6,11.5)
*/

// All share the same sprite (memory efficient), but have independent state
console.log(goblins[0].sprite === goblins[1].sprite); // true (shared)
goblins[0].hp = 30;
console.log(goblins[1].hp); // 50 (independent state)

Key insight: The clone method uses deep copy for state (hp, position, inventory) but shared reference for expensive assets (sprite). This is a hybrid approach that maximizes both correctness and efficiency.


When to Use

ScenarioWhy Prototype Helps
Object creation is expensiveClone once instead of re-computing
Many similar objects neededClone a template, tweak a few fields
Configuration templatesBase config cloned and customized per environment
Game entities / simulationSpawn many enemies from one prototype
Undo/redo (state snapshots)Clone current state before mutation
Object composition at runtimeBuild prototypes dynamically, clone for use

When NOT to Use

ScenarioWhy
Objects are cheap to createCloning adds unnecessary complexity
Objects have no shared structureNothing to clone
Deep circular referencesClone logic becomes complex
Objects mainly contain functionsstructuredClone can't copy functions

Before / After

Before: Repeated Expensive Construction

// Each call fetches config from DB, validates, computes defaults
function createReportConfig() {
  const defaults = loadDefaultsFromDB();       // 50ms
  const template = computeLayoutTemplate();    // 30ms
  const permissions = fetchPermissions();      // 40ms
  return { ...defaults, ...template, ...permissions }; // Total: 120ms
}

// Need 10 reports with slight variations
const reports = [];
for (let i = 0; i < 10; i++) {
  const config = createReportConfig(); // 120ms EACH TIME
  config.title = `Report ${i + 1}`;
  reports.push(config);
}
// Total: ~1200ms

After: Prototype Pattern

// Create one prototype with expensive initialization
const reportPrototype = createReportConfig(); // 120ms ONCE

// Clone for each report
const reports = [];
for (let i = 0; i < 10; i++) {
  const config = structuredClone(reportPrototype); // ~1ms each
  config.title = `Report ${i + 1}`;
  reports.push(config);
}
// Total: ~130ms (10x faster)

Common Pitfall: Forgetting Deep Copy

// BUG: Shallow copy shares nested objects
class BadPrototype {
  constructor() {
    this.name = 'default';
    this.settings = { volume: 50, brightness: 80 };
  }

  clone() {
    return Object.assign(new BadPrototype(), this); // SHALLOW!
  }
}

const original = new BadPrototype();
const copy = original.clone();

copy.settings.volume = 100;
console.log(original.settings.volume); // 100 <-- BUG! Original mutated!

// FIX: Use structuredClone for nested objects
class GoodPrototype {
  constructor() {
    this.name = 'default';
    this.settings = { volume: 50, brightness: 80 };
  }

  clone() {
    const cloned = new GoodPrototype();
    cloned.name = this.name;
    cloned.settings = structuredClone(this.settings); // DEEP!
    return cloned;
  }
}

Key Takeaways

  1. Prototype creates new objects by cloning existing ones, avoiding expensive re-initialization.
  2. Always use deep copy for nested objects unless you intentionally want shared references (like read-only assets).
  3. In JavaScript, structuredClone() is the modern standard for deep cloning. Use it over JSON round-trips.
  4. Object.create() is NOT cloning -- it sets up prototypal inheritance. Different concept, similar name.
  5. Combine Prototype with a registry to manage named templates that can be cloned on demand.
  6. The hybrid approach of deep copy for state + shared reference for assets gives you both correctness and memory efficiency.

Explain-It Challenge: Your team manages cloud infrastructure configs. The base config has 200+ fields and takes 3 seconds to validate. Each deployment needs a slightly different version. Explain why the Prototype pattern saves both time and sanity, in two sentences.