Prototype

Intermediate · 11 min read · ▶ live playground · ✦ checkpoint

Spawning fifty frost wardens reloads the same model, rebuilds the same resistance table, and repeats the same loot configuration fifty times. Only the enemy name and health change, yet every spawn starts the expensive setup from zero.

The Prototype pattern creates a new object by cloning an existing configured instance. The configured object is the prototype; new objects begin as copies and then receive their small differences.

Prototype trades repeated construction for copy semantics. The gain can be substantial when setup is expensive or elaborate. The cost is that every mutable field must be copied at the right depth, and that rule must stay current as the class evolves.

Before: every spawn repeats the full recipe

EnemySpawner knows the concrete Enemy constructor and every detail of the frost-warden configuration. A second enemy still pays for the same assembly work.

class diagram

Before: the spawner rebuilds every Enemy

Before: the spawner rebuilds every Enemy
  1. 1EnemySpawner owns the complete frost-warden construction recipe.
  2. 2Each spawn constructs Enemy and rebuilds the same nested configuration.
  3. 3The few changing values are buried inside a repeated, expensive setup path.

The example uses strings and objects, but those values can stand in for parsed models, validated rule sets, or data loaded from disk:

type EnemyStats = {
  health: number;
  resistances: string[];
};
 
type LootItem = {
  name: string;
  dropChance: number;
};
 
class Enemy {
  public name: string;
  public readonly modelPath: string;
  public stats: EnemyStats;
  public loot: LootItem[];
 
  public constructor(
    name: string,
    modelPath: string,
    stats: EnemyStats,
    loot: LootItem[],
  ) {
    this.name = name;
    this.modelPath = modelPath;
    this.stats = stats;
    this.loot = loot;
  }
}
 
class EnemySpawner {
  private modelPath = "models/frost-warden.mesh";
 
  public spawnFrostWarden(name: string): Enemy {
    // smell: the same elaborate configuration is rebuilt for every spawn.
    return new Enemy(
      name,
      this.modelPath,
      { health: 120, resistances: ["ice"] },
      [{ name: "frost crystal", dropChance: 0.25 }],
    );
  }
}

A factory can hide this recipe, but it still reconstructs the recipe on every call. Prototype changes the starting point: configure once, then copy.

After: clone a configured prototype

A prototype contract is an interface that asks an object to return a copy of itself. The concrete class knows its own fields and therefore owns the cloning policy.

class diagram

After: Enemy clones a configured prototype

After: Enemy clones a configured prototype
  1. 1Prototype gives clients one operation for copying an already configured object.
  2. 2Enemy implements clone() because it knows which nested values require new copies.
  3. 3A clone keeps the template's setup while remaining independent when mutable data changes.

The generic TypeScript interface preserves the concrete return type. Enemy.clone() copies the outer object, its nested stats object, the resistance array, and every loot item:

type EnemyStats = {
  health: number;
  resistances: string[];
};
 
type LootItem = {
  name: string;
  dropChance: number;
};
 
interface Prototype<T> {
  clone(): T;
}
 
class Enemy implements Prototype<Enemy> {
  public name: string;
  public readonly modelPath: string;
  public stats: EnemyStats;
  public loot: LootItem[];
 
  public constructor(
    name: string,
    modelPath: string,
    stats: EnemyStats,
    loot: LootItem[],
  ) {
    this.name = name;
    this.modelPath = modelPath;
    this.stats = stats;
    this.loot = loot;
  }
 
  public clone(): Enemy {
    return new Enemy(
      this.name,
      this.modelPath,
      {
        health: this.stats.health,
        resistances: [...this.stats.resistances],
      },
      this.loot.map((item) => ({ ...item })),
    );
  }
 
  public summary(): string {
    const chances = this.loot
      .map((item) => String(item.dropChance))
      .join("/");
 
    return (
      this.name +
      ": " +
      this.stats.health +
      " HP, " +
      this.stats.resistances.join("/") +
      ", " +
      chances
    );
  }
}
 
const frostWarden = new Enemy(
  "Frost Warden",
  "models/frost-warden.mesh",
  { health: 120, resistances: ["ice"] },
  [{ name: "frost crystal", dropChance: 0.25 }],
);
 
const scout = frostWarden.clone();
scout.name = "Frost Scout";
scout.stats.health = 80;
scout.stats.resistances.push("wind");
scout.loot = scout.loot.map((item) => ({
  ...item,
  dropChance: 0.1,
}));
 
console.log(frostWarden.summary());
console.log(scout.summary());

Frost Warden: 120 HP, ice, 0.25 Frost Scout: 80 HP, ice/wind, 0.1

The original keeps 120 health, one resistance, and its 0.25 drop chance. Mutating the clone changes only the clone because no nested mutable object or array is shared.

A registry can hold several configured prototypes—frost warden, fire mage, stone guard—and choose one to clone by key. The registry is optional. The pattern's essential move is that creation delegates to an existing object's clone() behavior.

Shallow copy versus deep copy

A shallow copy creates a new outer object but reuses references to nested objects and arrays. Object spread, { ...enemy }, is shallow.

A deep copy creates independent copies of nested mutable values as well as the outer object. The clone() above is deep for all of Enemy's current mutable data.

The distinction matters because a shallow clone can look independent until a nested mutation occurs. If the original and clone share stats.resistances, pushing "wind" through the clone also changes the prototype. That is the classic Prototype footgun: two identities with secretly shared state.

“Deep” is relative to the object graph and its ownership rules. An immutable model asset can be shared safely, so modelPath does not need a special copy. Mutable stats and loot belong to each enemy, so they must not be shared.

Built-in copying in TypeScript

For plain data, TypeScript and JavaScript already provide useful tools. Spread syntax is concise when a shallow copy is intentional. structuredClone() makes a deep copy of supported data such as objects, arrays, maps, and sets.

Those tools do not remove the design question. Spread keeps nested references. structuredClone() cannot clone every resource, and a cloned custom class should not be assumed to preserve its class methods and prototype. An explicit clone() remains useful when copying must preserve invariants, share selected immutable data, or transform fields.

When NOT to use it

If an object has a cheap, clear constructor, new communicates creation better. Cloning a two-field value adds an interface and a copy policy without saving meaningful work.

Avoid Prototype when the object graph contains unclear ownership, open files, live connections, callbacks, or other values with no honest copy meaning. A fragile “deep clone” that misses one new mutable field is worse than repeating a constructor because the failure appears later as shared-state corruption.

For plain records, object spread or structuredClone() often suffices. Use Prototype when configured instances are natural templates, construction is expensive or elaborate, and the class can define a trustworthy independence boundary.

Checkpoint

Answer all three to mark this lesson complete

Creation is now solved from one instance through whole families. The next pattern family is about how objects are composed and wrapped, beginning with Adapter.

+50 XP on completion