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
Dateobjects (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.createshares behavior through the prototype chain.- Cloning creates a fully independent copy.
- Use
Object.createwhen 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
| Scenario | Why Prototype Helps |
|---|---|
| Object creation is expensive | Clone once instead of re-computing |
| Many similar objects needed | Clone a template, tweak a few fields |
| Configuration templates | Base config cloned and customized per environment |
| Game entities / simulation | Spawn many enemies from one prototype |
| Undo/redo (state snapshots) | Clone current state before mutation |
| Object composition at runtime | Build prototypes dynamically, clone for use |
When NOT to Use
| Scenario | Why |
|---|---|
| Objects are cheap to create | Cloning adds unnecessary complexity |
| Objects have no shared structure | Nothing to clone |
| Deep circular references | Clone logic becomes complex |
| Objects mainly contain functions | structuredClone 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
- Prototype creates new objects by cloning existing ones, avoiding expensive re-initialization.
- Always use deep copy for nested objects unless you intentionally want shared references (like read-only assets).
- In JavaScript,
structuredClone()is the modern standard for deep cloning. Use it over JSON round-trips. Object.create()is NOT cloning -- it sets up prototypal inheritance. Different concept, similar name.- Combine Prototype with a registry to manage named templates that can be cloned on demand.
- 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.