Prototypes are JavaScript’s DNA
The Prototype Design Pattern is one of the most fundamental creational patterns, but in JavaScript it holds a special place: it’s not just a pattern — it’s the very fabric of the language. In JavaScript every object is inherently linked to another object through a prototype chain. This makes the Prototype Pattern both natural and powerful: instead of stamping out rigid copies with constructors or factories, you create new objects by cloning existing ones and letting property lookups flow upward lazily. Why does this matter? Because in real-world systems, requirements rarely stay static. A CMS may need new user roles defined on the fly, a game may spawn new enemy types at runtime, or configuration snapshots may evolve from dev to staging then production. Classes and factories force you to anticipate these variations upfront, but prototypes let you fork, override, and extend behavior dynamically, without tearing apart existing hierarchies. The Prototype Pattern in JavaScript isn’t just about memory savings or syntactic sugar — it’s about embracing a model of object creation that is flexible, incremental, and runtime-driven, giving developers a powerful lever to design systems that can adapt as fast as the problems they’re solving.
1. Prototype is a creational Pattern (decides how objects are created)
Prototypes are useful as they allow cloning at runtime thus a useful feature for creating dynamic applications such as games or those involving dynamic user interface states. In JavaScript, the prototype lets us create new objects by cloning existing ones, rather than instantiating classes as show in the example below.
// Base prototype
const baseUser = {
role: "guest",
permissions: ["read"],
describe() {
return `${this.role} can ${this.permissions.join(", ")}`;
},
};
// Clone at runtime
const admin = Object.create(baseUser);
admin.role = "admin";
admin.permissions = ["read", "write", "delete"];
console.log(baseUser.describe()); // guest can read
console.log(admin.describe()); // admin can read, write, delete
In the above code note how Object.create doesn’t eagerly copy properties or methods describe — it defers property lookups through the prototype chain. Both data and behavior are lazily delegated, which saves memory but couples clones to their prototype’s state and evolution.This lazy delegation is core to the prototype pattern.
2. Prototypes verses classes
When we are building domains that evolve unpredictably prototypes win as we can fork objects at runtime (dynamic) unlike in classes where we have to decide upfront what variations exist (static).
- a. Dynamic roles verses shared state using CMS example
Class hierarchy here is rigid
// Class hierarchy (rigid)
class User {
constructor(role) { this.role = role; }
permissions() { return []; }
}
class Admin extends User {
permissions() { return ["read", "write", "delete"]; }
}
class Guest extends User {
permissions() { return ["read"]; }
}
// Adding "Moderator" requires touching the class tree.
With prototypes
// Prototype-based (runtime flexibility)
const baseUser = { role: "guest", permissions: ["read"] };
function createRole(base, role, perms) {
// Instead of defining rigid subclasses (Admin, Guest, etc.),
// we dynamically "fork" baseUser at runtime.
const newRole = Object.create(base);
// Variations can be introduced on the fly:
newRole.role = role;
newRole.permissions = perms;
// This means new roles can be invented *without touching a class tree*.
return newRole;
}
// Create new roles at runtime — no need for static class definitions.
const moderator = createRole(baseUser, "moderator", ["read", "ban"]);
const superAdmin = createRole(baseUser, "superAdmin", ["*"]);
console.log(moderator.permissions); // ["read", "ban"]
// Super flexible: roles can evolve dynamically,
// perfect for CMS systems where user roles are often user-defined
// (and you can't anticipate them all upfront).
But!
baseUser.permissions.push("oops!");
console.log(superAdmin.permissions);
// ["*", "oops!"] - unexpected inheritance leak
- b. evolution vs mutation hazards using game example Here we see flexibility which is very useful for games — we can spawn new creatures by forking.
// Base NPC
const npc = { hp: 100, attack: 5 };
// Fork prototypes at runtime
const orc = Object.create(npc);
orc.attack = 15;
const dragon = Object.create(orc);
dragon.hp = 1000;
dragon.attack = 100;
console.log(dragon.hp); // 1000
console.log(dragon.attack); // 100
But if base changes at runtime!
npc.hp = 50;
console.log(orc.hp); // 50
console.log(dragon.hp); // 50 (overridden later, but dangerous default!)
All descendants inherit that mutation. Suddenly, every NPC in the game world just got weaker because a prototype changed. Debugging that is painful.
- c. Dealing with shared state hazards Clone deeply instead of shallow delegation so each role gets its own independent copy
function safeCreateRole(base, role, perms) {
return { ...base, role, permissions: [...perms] };
}
Freeze base prototypes (when immutability is critical)
Object.freeze(baseUser);
Hybrid approach - use prototypes for methods (shared safely) and use fresh copies for stateful data (arrays, objects)
const userProto = {
describe() { return `${this.role}: ${this.permissions.join(", ")}`; }
};
const baseUser = Object.create(userProto);
baseUser.role = "guest";
baseUser.permissions = ["read"];
takeaway
Prototypes shine in domains that demand runtime evolution (CMS roles, game entities), but shared state is the hidden landmine. You gain efficiency, but must consciously trade off safety. The fix is to separate shared behavior (safe on the prototype) from mutable state (copied per object).
3. Prototype vs Factory
A Factory centralizes object creation. It’s great when you need control over instantiation logic (e.g., caching, dependency injection) while factories often lead to boilerplate and rigid “switch” logic. Prototypes sidestep this by letting objects clone themselves.
Factory
// Factory-based (switch explosion)
function userFactory(type) {
switch (type) {
case "admin": return { role: "admin", perms: ["*"] };
case "editor": return { role: "editor", perms: ["read", "write"] };
default: return { role: "guest", perms: ["read"] };
}
}
with prototypes
// Prototype-based (cloning avoids switch)
const baseUser = { role: "guest", perms: ["read"] };
const editor = Object.create(baseUser);
editor.role = "editor";
editor.perms = ["read", "write"];
const admin = Object.create(baseUser);
admin.role = "admin";
admin.perms = ["*"];
Prototype wins when the we needs to scale variations dynamically without centralizing brittle logic
4. Prototype as a config snapshot
Imagine a system where environments evolve: dev → staging → prod. Rather than rebuilding configs, we can clone and override.
const baseConfig = {
db: "localhost",
cache: false,
logging: "verbose",
};
const staging = Object.create(baseConfig);
staging.db = "staging-db";
const prod = Object.create(staging);
prod.db = "prod-db";
prod.cache = true;
prod.logging = "error";
5. when not to use protototypes or prototype pattern
- When we need strict immutability if configs must never leak changes backward, prototypes are risky
const prod = Object.create(baseConfig);
prod.cache = true;
// baseConfig still mutable — not safe for critical immutable systems
- When you need explicitness, factories or classes could be more readable. Deep prototype chains make it hard to know where a property is coming from.
console.log(prod.logging); // does it come from staging or baseConfig?
Also note explicit class User declarations communicate intent faster than hunting through a prototype chain.
6. Take note
The trickiest bugs come from not realizing an object is delegating.
const proto = { bigArray: new Array(1000000).fill("*") };
const clone = Object.create(proto);
console.log(clone.bigArray === proto.bigArray); // true (shared!)
To avoid shared state bugs
- Prototypes are “lazy”: no duplication of heavy data, only delegation.
- mutating proto.bigArray affects all clones.
javascript book
If this interested you, check out my Javascript Book