Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 015 / 100

Prototype pattern: a Design Patterns guide for developers

Create new objects by copying an existing one.

Expona Admin9 min read

Introduction

Imagine you're building a complex configuration object for a database connection pool, complete with timeout settings, retry logic, and SSL certificates. Creating one from scratch every time would be tedious and error-prone. The Prototype pattern: a Design Patterns guide for developers solves this by letting you clone an existing, already-configured object instead of building new ones from the ground up. This pattern is particularly valuable when object initialization is expensive, involves multiple steps, or when you want to ensure consistency across similar instances.

We'll explore how to leverage prototyping in modern JavaScript and TypeScript, examining when it makes sense, how to implement it correctly, and the pitfalls to avoid.

Clone preconfigured templates

The core idea of the Prototype pattern is straightforward: keep a template object with sensible defaults, and when you need a new instance, clone it rather than instantiating from scratch. This is especially powerful when you have multiple configuration variants you want to reuse.

Consider an e-commerce platform where you need to create multiple order objects. Each order has a customer, shipping address, items, tax calculator, and payment processor reference. Rather than assembling these each time, you maintain a prototype and clone it:

typescript
interface Order {
  id: string;
  customerId: string;
  items: OrderItem[];
  shippingAddress: Address;
  taxCalculator: TaxCalculator;
  paymentProcessor: PaymentProcessor;
  createdAt: Date;
  status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
}

class OrderPrototype {
  private prototype: Order;

  constructor(template: Order) {
    this.prototype = template;
  }

  clone(): Order {
    return structuredClone(this.prototype);
  }
}

// In your order service
const baseOrderPrototype = new OrderPrototype({
  id: crypto.randomUUID(),
  customerId: '',
  items: [],
  shippingAddress: defaultAddress,
  taxCalculator: new USATaxCalculator(),
  paymentProcessor: stripeProcessor,
  createdAt: new Date(),
  status: 'pending'
});

const newOrder = baseOrderPrototype.clone();
newOrder.customerId = 'cust-12345';
newOrder.items = selectedItems;

This approach scales nicely. If your business operates in multiple regions, you can maintain region-specific prototypes with their own tax calculators and default shipping origins:

typescript
const usOrderPrototype = new OrderPrototype({
  id: crypto.randomUUID(),
  customerId: '',
  items: [],
  shippingAddress: usDefaultAddress,
  taxCalculator: new USATaxCalculator(),
  paymentProcessor: stripeProcessor,
  createdAt: new Date(),
  status: 'pending'
});

const euOrderPrototype = new OrderPrototype({
  id: crypto.randomUUID(),
  customerId: '',
  items: [],
  shippingAddress: euDefaultAddress,
  taxCalculator: new EUVATCalculator(),
  paymentProcessor: paypalProcessor,
  createdAt: new Date(),
  status: 'pending'
});

// Later, based on customer region
const order = customerRegion === 'US' 
  ? usOrderPrototype.clone() 
  : euOrderPrototype.clone();

The benefit isn't just convenience—it's consistency. Every order starts with exactly the right configuration for its region, reducing bugs and configuration errors.

Useful when setup is expensive

The Prototype pattern truly shines when object initialization involves costly operations. Think about scenarios where you're loading large datasets, establishing connections, or running complex calculations during setup.

In a ride-hailing application, you might initialize a rider session with geographic data, historical preferences, and cached routes. Setting this up for every request would kill performance:

typescript
interface RiderSession {
  riderId: string;
  location: GeoCoordinates;
  preferences: RiderPreferences;
  cachedRoutes: Map;
  historicalTrips: Trip[];
  ratingHistory: RatingData;
  walletBalance: number;
}

class ExpensiveRiderSessionSetup {
  private static loadHistoricalTrips(riderId: string): Trip[] {
    // Expensive: queries database, deserializes large dataset
    const trips = database.query(
      'SELECT * FROM trips WHERE rider_id = ? LIMIT 1000',
      [riderId]
    );
    return trips.map(t => new Trip(t));
  }

  private static loadCachedRoutes(): Map {
    // Expensive: hits Redis, geo-encodes common destinations
    const cached = redis.get('common_routes');
    return new Map(JSON.parse(cached));
  }

  static create(riderId: string): RiderSession {
    return {
      riderId,
      location: geoService.getCurrentLocation(riderId),
      preferences: database.query('SELECT preferences FROM riders WHERE id = ?', [riderId])[0],
      cachedRoutes: this.loadCachedRoutes(),
      historicalTrips: this.loadHistoricalTrips(riderId),
      ratingHistory: ratingService.getHistory(riderId),
      walletBalance: walletService.getBalance(riderId)
    };
  }
}

// Without Prototype: creating a new session is slow
const session1 = ExpensiveRiderSessionSetup.create('rider-1');

// With Prototype: clone the expensive setup once
class RiderSessionPrototype {
  constructor(private template: RiderSession) {}

  clone(newRiderId: string): RiderSession {
    const cloned = structuredClone(this.template);
    cloned.riderId = newRiderId;
    return cloned;
  }
}

// Initialize once during app startup
const prototypeSession = ExpensiveRiderSessionSetup.create('prototype-rider');
const riderPrototype = new RiderSessionPrototype(prototypeSession);

// Later, creating sessions is nearly instant
const session1 = riderPrototype.clone('rider-1');
const session2 = riderPrototype.clone('rider-2');
const session3 = riderPrototype.clone('rider-3');

This approach reduces initialization time from hundreds of milliseconds to microseconds. The trade-off is that the cloned data might become stale—in this example, the historical trips and cached routes are snapshots from prototype initialization time. You'd need to refresh critical fields after cloning if freshness matters.

structuredClone for deep copies in JavaScript

JavaScript's simple Object.assign() or spread operator only creates shallow copies. If your prototype contains nested objects or arrays, modifications to nested properties will affect the original:

typescript
const shallowProblem = {
  name: 'Product A',
  variants: [
    { sku: 'VAR-1', price: 100 }
  ]
};

const copy1 = { ...shallowProblem };
const copy2 = { ...shallowProblem };

// Both copies share the same variants array
copy1.variants[0].price = 200;
console.log(copy2.variants[0].price); // 200 — oops!
console.log(shallowProblem.variants[0].price); // 200 — corrupted prototype!

For true independence, use structuredClone(), which performs a deep copy of the entire object graph:

typescript
const productPrototype = {
  name: 'Product A',
  variants: [
    { sku: 'VAR-1', price: 100 },
    { sku: 'VAR-2', price: 120 }
  ],
  tags: ['electronics', 'premium'],
  metadata: {
    createdBy: 'admin',
    reviews: [
      { rating: 5, text: 'Great!' }
    ]
  }
};

const product1 = structuredClone(productPrototype);
const product2 = structuredClone(productPrototype);

// Now each is truly independent
product1.variants[0].price = 150;
product1.tags.push('sale');
product1.metadata.reviews[0].rating = 4;

console.log(product2.variants[0].price); // 100 — unchanged
console.log(product2.tags); // ['electronics', 'premium'] — unchanged
console.log(product2.metadata.reviews[0].rating); // 5 — unchanged
console.log(productPrototype.variants[0].price); // 100 — prototype safe

structuredClone() is widely supported in modern browsers and Node.js (v17+). It handles most types correctly: objects, arrays, Maps, Sets, Dates, RegExes, TypedArrays, and more. However, it cannot clone functions, DOM nodes, or objects with circular references, so ensure your prototypes only contain serializable data.

Watch out for shared references

Even with structuredClone(), you need to be careful about what you include in your prototype. Some objects—like database connection pools, HTTP clients, or message queues—should never be cloned because they maintain stateful connections.

typescript
class DatabaseClient {
  private connectionPool: ConnectionPool;
  
  constructor() {
    this.connectionPool = new ConnectionPool('postgresql://...');
  }

  async query(sql: string) {
    return this.connectionPool.query(sql);
  }
}

// WRONG: If you clone an object containing a DatabaseClient,
// the clone gets a deep copy of the pool, creating multiple independent pools
const clientPrototype = {
  id: 'session-1',
  db: new DatabaseClient(),
  cache: new RedisClient()
};

const session1 = structuredClone(clientPrototype);
const session2 = structuredClone(clientPrototype);

// Now we have THREE connection pools instead of one!
// session1 and session2 each have their own pools,
// plus the original prototype's pool.

Instead, keep shared resources outside the prototype and pass them as constructor arguments or inject them after cloning:

typescript
interface SessionPrototype {
  id: string;
  userId: string;
  preferences: UserPreferences;
  sessionData: Map;
}

class SessionManager {
  private sharedDb: DatabaseClient;
  private sharedCache: RedisClient;
  private prototype: SessionPrototype;

  constructor(
    db: DatabaseClient,
    cache: RedisClient
  ) {
    this.sharedDb = db;
    this.sharedCache = cache;
    
    this.prototype = {
      id: crypto.randomUUID(),
      userId: '',
      preferences: defaultPreferences,
      sessionData: new Map()
    };
  }

  createSession(userId: string): Session {
    const sessionData = structuredClone(this.prototype);
    
    return new Session(
      sessionData.id,
      userId,
      sessionData.preferences,
      sessionData.sessionData,
      this.sharedDb,     // Shared reference, not cloned
      this.sharedCache   // Shared reference, not cloned
    );
  }
}

This way, each session gets its own data and state, but all sessions share a single database connection and cache instance.

Common mistakes

  • Forgetting deep copies for nested structures: Using shallow copy methods and then modifying nested properties will corrupt your prototype. Always use structuredClone() if you have complex nested data.
  • Cloning expensive-to-copy objects: If your prototype contains large byte arrays, dense graphs, or many serializable objects, cloning itself becomes expensive. In that case, consider lazy initialization or storing only lightweight metadata in the prototype.
  • Cloning stateful resources: Connection pools, streams, and event emitters should never be cloned. Extract them from the prototype and share them across instances.
  • Mutating the prototype after creating clones: If you modify the prototype object after cloning, and someone uses shallow copy elsewhere in the codebase, inconsistencies emerge. Mark prototypes as frozen or document immutability expectations.
  • Ignoring prototype initialization overhead: The Prototype pattern only saves time if initialization is genuinely expensive. For lightweight objects, the overhead of maintaining a prototype and cloning might outweigh benefits.

Key takeaways

  • Use the Prototype pattern to clone preconfigured templates, avoiding repetitive setup code and reducing initialization errors.
  • The pattern shines when object creation is expensive—database queries, API calls, complex calculations, or loading large datasets during initialization.
  • Always use structuredClone() for true deep copies in JavaScript and TypeScript, especially when your prototype contains nested objects or arrays.
  • Keep stateful resources (connection pools, clients, streams) outside the prototype and share them across cloned instances.
  • Document which fields are safe to modify after cloning and which are shared with other instances, to prevent subtle bugs.
  • Profile before optimizing: only adopt the Prototype pattern if initialization overhead is measurable in your production workloads.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles