Loading content...
When you clone an object, a crucial question emerges: What happens to the objects it references? The answer determines whether your copy is truly independent or secretly shares state with the original—a distinction that can mean the difference between working software and mysterious bugs that appear seemingly at random.\n\nThis is the realm of shallow vs deep copying—perhaps the most important technical decision when implementing the Prototype Pattern. Get it wrong, and modifications to one object silently corrupt another. Get it right, and you have a robust, predictable cloning system.
By the end of this page, you will understand the precise semantics of shallow and deep copying, recognize which approach is appropriate for different scenarios, implement both correctly in production code, and avoid the common pitfalls that lead to copy-related bugs.
Let's establish precise definitions that will guide our understanding:\n\nShallow Copy: Creates a new object and copies all field values from the original. For primitive fields (numbers, booleans, strings), this copies the values. For reference fields (objects, arrays), this copies the references—meaning both original and copy point to the same nested objects.\n\nDeep Copy: Creates a new object and recursively copies all objects reachable from the original. Each nested object gets its own independent copy. The result is a completely separate object graph with no shared references.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Visualizing the difference class Address { constructor( public street: string, public city: string ) {}} class Person { constructor( public name: string, // Primitive: always copied by value public age: number, // Primitive: always copied by value public address: Address // Reference: copying behavior varies! ) {}} const original = new Person( "Alice", 30, new Address("123 Main St", "Springfield")); // ============================================// SHALLOW COPY// ============================================function shallowClone(person: Person): Person { return new Person( person.name, person.age, person.address // Same reference! Not a new Address );} const shallowCopy = shallowClone(original); // Primitives are independentshallowCopy.name = "Bob";console.log(original.name); // "Alice" - unaffected ✓ // But references are SHAREDshallowCopy.address.city = "Shelbyville";console.log(original.address.city); // "Shelbyville" - AFFECTED! ✗ // original.address === shallowCopy.address (same object in memory) // ============================================// DEEP COPY// ============================================function deepClone(person: Person): Person { return new Person( person.name, person.age, new Address( // NEW Address object person.address.street, person.address.city ) );} const deepCopy = deepClone(original); // Primitives are independentdeepCopy.name = "Charlie";console.log(original.name); // "Alice" - unaffected ✓ // References are also independent!deepCopy.address.city = "Capital City";console.log(original.address.city); // "Shelbyville" - UNAFFECTED ✓ // original.address !== deepCopy.address (different objects)Most language-level copy mechanisms produce shallow copies by default. JavaScript's spread operator, Object.assign(), Array.slice(), and similar constructs all create shallow copies. If you need deep copying, you must implement it explicitly.
To truly understand shallow vs deep copying, we need to visualize what happens in memory. Objects in memory are graphs: the root object and all objects reachable through its references form an interconnected structure.\n\nShallow copy: Duplicates only the root node of the graph. All edges point to the same nested objects as the original.\n\nDeep copy: Duplicates the entire graph. Every reachable node gets its own copy, with edges reconnected to point to the copies.
| Aspect | Shallow Copy | Deep Copy |
|---|---|---|
| Memory allocation | One new object (the clone) | One new object per node in graph |
| Time complexity | O(1) or O(n) for n direct fields | O(N) where N = total reachable objects |
| Space complexity | O(1) additional space for references | O(N) for complete duplicate graph |
| Independence | Only root object is independent | Entire object graph is independent |
| Shared state | Nested objects are shared | No shared mutable state |
| Mutation visibility | Changes to nested objects visible in both | Changes visible only in modified copy |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
// Complex object graph exampleinterface Cloneable<T> { clone(): T;} class Department implements Cloneable<Department> { name: string; budget: number; constructor(name: string, budget: number) { this.name = name; this.budget = budget; } clone(): Department { return new Department(this.name, this.budget); }} class Employee implements Cloneable<Employee> { name: string; salary: number; department: Department; // Reference to shared department directReports: Employee[]; // References to other employees constructor(name: string, salary: number, department: Department) { this.name = name; this.salary = salary; this.department = department; this.directReports = []; } // SHALLOW clone - good for some use cases shallowClone(): Employee { const copy = new Employee(this.name, this.salary, this.department); copy.directReports = this.directReports; // Same array reference! return copy; } // DEEP clone - creates complete independent copy deepClone( clonedDepartments: Map<Department, Department> = new Map(), clonedEmployees: Map<Employee, Employee> = new Map() ): Employee { // Check if we've already cloned this employee (circular reference handling) if (clonedEmployees.has(this)) { return clonedEmployees.get(this)!; } // Clone or reuse department let clonedDept = clonedDepartments.get(this.department); if (!clonedDept) { clonedDept = this.department.clone(); clonedDepartments.set(this.department, clonedDept); } // Create the employee clone const copy = new Employee(this.name, this.salary, clonedDept); clonedEmployees.set(this, copy); // Register before recursing (for cycles) // Deep clone each direct report copy.directReports = this.directReports.map(report => report.deepClone(clonedDepartments, clonedEmployees) ); return copy; }} // Usage demonstrationconst engineering = new Department("Engineering", 1000000);const manager = new Employee("Alice", 150000, engineering);const developer1 = new Employee("Bob", 100000, engineering);const developer2 = new Employee("Charlie", 100000, engineering); manager.directReports = [developer1, developer2]; // Shallow clone shares department and reportsconst shallowManager = manager.shallowClone();shallowManager.department.budget = 2000000;console.log(manager.department.budget); // 2000000 - MODIFIED! // Deep clone is completely independentconst deepManager = manager.deepClone();deepManager.department.budget = 3000000;console.log(manager.department.budget); // 2000000 - Unchanged ✓Shallow copying is not inherently wrong—in many scenarios, it's exactly what you want. Understanding when shallow copies are appropriate helps you avoid unnecessary complexity and performance overhead.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Example: Shallow copy with immutable nested objects// This is SAFE because Color is immutable class Color { // Immutable: all fields are readonly constructor( public readonly r: number, public readonly g: number, public readonly b: number ) {} // "Mutation" methods return new instances withAlpha(a: number): ColorWithAlpha { return new ColorWithAlpha(this.r, this.g, this.b, a); } lighten(amount: number): Color { return new Color( Math.min(255, this.r + amount), Math.min(255, this.g + amount), Math.min(255, this.b + amount) ); }} class ColorWithAlpha extends Color { constructor( r: number, g: number, b: number, public readonly a: number ) { super(r, g, b); }} class Pixel { constructor( public x: number, public y: number, public color: Color // Reference to immutable object ) {} // Shallow clone is SAFE here clone(): Pixel { return new Pixel(this.x, this.y, this.color); } // If we need a different color, we create a new one withColor(newColor: Color): Pixel { return new Pixel(this.x, this.y, newColor); }} const red = new Color(255, 0, 0);const pixel1 = new Pixel(10, 20, red);const pixel2 = pixel1.clone(); // Modifying pixel2's position doesn't affect pixel1pixel2.x = 100;console.log(pixel1.x); // 10 - unaffected ✓ // Even though color is shared, it can't be mutated// To change color, we must create new Color and new Pixelconst pixel3 = pixel2.withColor(red.lighten(50));console.log(pixel2.color === red); // true - pixel2 still has redconsole.log(pixel3.color === red); // false - pixel3 has lighter colorIf you design nested objects to be immutable, shallow copying becomes safe by default. This is why functional programming styles and languages with immutable-by-default semantics (Scala, Clojure) rarely worry about deep copying—mutation isn't possible.
Deep copying is essential when the copy must be completely independent—when modifications to either the original or the copy must never affect the other. This is the more common requirement in practice for mutable objects.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// Example: Undo system REQUIRES deep copy// Shallow copy would break undo functionality completely class DocumentState { constructor( public title: string, public content: string[], public metadata: Map<string, string> ) {} // MUST be deep copy for undo to work correctly clone(): DocumentState { return new DocumentState( this.title, [...this.content], // New array with same strings new Map(this.metadata) // New Map with same entries ); }} class UndoableDocument { private state: DocumentState; private history: DocumentState[] = []; private currentIndex: number = -1; constructor(title: string) { this.state = new DocumentState(title, [], new Map()); this.saveState(); } // Capture current state for undo private saveState(): void { // CRITICAL: Must deep clone to preserve state // With shallow copy, modifying current state would modify history! // Remove any redo states this.history = this.history.slice(0, this.currentIndex + 1); // Save deep copy of current state this.history.push(this.state.clone()); this.currentIndex++; } // User action: add content addParagraph(text: string): void { this.state.content.push(text); this.saveState(); } // User action: modify content modifyParagraph(index: number, newText: string): void { if (index >= 0 && index < this.state.content.length) { this.state.content[index] = newText; this.saveState(); } } // User action: set metadata setMetadata(key: string, value: string): void { this.state.metadata.set(key, value); this.saveState(); } // Undo: restore previous state undo(): boolean { if (this.currentIndex > 0) { this.currentIndex--; // Deep clone the historical state to become current this.state = this.history[this.currentIndex].clone(); return true; } return false; } // Redo: restore next state redo(): boolean { if (this.currentIndex < this.history.length - 1) { this.currentIndex++; this.state = this.history[this.currentIndex].clone(); return true; } return false; } getContent(): string[] { return this.state.content; }} // Usageconst doc = new UndoableDocument("My Document"); doc.addParagraph("First paragraph.");doc.addParagraph("Second paragraph.");doc.modifyParagraph(0, "Modified first paragraph."); console.log(doc.getContent());// ["Modified first paragraph.", "Second paragraph."] doc.undo(); // Go back before modificationconsole.log(doc.getContent());// ["First paragraph.", "Second paragraph."] doc.undo(); // Go back before second paragraphconsole.log(doc.getContent());// ["First paragraph."] doc.redo(); // Restore second paragraphconsole.log(doc.getContent());// ["First paragraph.", "Second paragraph."] // Without deep copy, undo would NOT work correctly!// All history entries would share the same content array,// so they would all show the current state, not historical states.Deep copying is more complex than it first appears. You must handle nested objects recursively, deal with circular references, and make decisions about what to clone versus share. Here are robust implementation strategies:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Recursive deep copy with cycle detectioninterface DeepCloneable<T> { deepClone(cloneMap?: Map<object, object>): T;} class Author implements DeepCloneable<Author> { constructor( public name: string, public email: string ) {} deepClone(cloneMap: Map<object, object> = new Map()): Author { // Check if already cloned (cycle detection) if (cloneMap.has(this)) { return cloneMap.get(this) as Author; } const copy = new Author(this.name, this.email); cloneMap.set(this, copy); return copy; }} class Comment implements DeepCloneable<Comment> { constructor( public author: Author, public text: string, public replies: Comment[] ) {} deepClone(cloneMap: Map<object, object> = new Map()): Comment { if (cloneMap.has(this)) { return cloneMap.get(this) as Comment; } // Create comment with placeholder values first const copy = new Comment( null as any, // Will set after registering this.text, [] ); // Register immediately to handle cycles cloneMap.set(this, copy); // Now safely clone nested objects copy.author = this.author.deepClone(cloneMap); copy.replies = this.replies.map(reply => reply.deepClone(cloneMap)); return copy; }} class BlogPost implements DeepCloneable<BlogPost> { constructor( public title: string, public content: string, public author: Author, public tags: string[], public comments: Comment[] ) {} deepClone(cloneMap: Map<object, object> = new Map()): BlogPost { if (cloneMap.has(this)) { return cloneMap.get(this) as BlogPost; } const copy = new BlogPost( this.title, this.content, null as any, [...this.tags], // Shallow copy of primitives array [] ); cloneMap.set(this, copy); copy.author = this.author.deepClone(cloneMap); copy.comments = this.comments.map(c => c.deepClone(cloneMap)); return copy; }}Circular references occur when objects reference each other directly or indirectly (A → B → C → A). Naive deep copying will infinite loop or stack overflow. A robust implementation must detect and handle cycles.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// Handling circular references in deep clone // Example: Tree node with parent reference (creates cycle)class TreeNode<T> { value: T; children: TreeNode<T>[] = []; parent: TreeNode<T> | null = null; // Back-reference creates cycle! constructor(value: T) { this.value = value; } addChild(child: TreeNode<T>): void { child.parent = this; // Creates cycle: child -> parent -> child this.children.push(child); } // Naive deep clone would infinite loop: // clone() -> clones children -> each child clones parent -> // parent clones children -> never ends! // Correct implementation with clone registry deepClone(cloneRegistry: Map<TreeNode<T>, TreeNode<T>> = new Map()): TreeNode<T> { // STEP 1: Check if already cloned (break the cycle) if (cloneRegistry.has(this)) { return cloneRegistry.get(this)!; } // STEP 2: Create new node (don't clone children yet) const cloned = new TreeNode(this.value); // STEP 3: Register BEFORE recursing (critical for cycles!) cloneRegistry.set(this, cloned); // STEP 4: Now safe to clone children for (const child of this.children) { const clonedChild = child.deepClone(cloneRegistry); clonedChild.parent = cloned; // Point to cloned parent cloned.children.push(clonedChild); } // STEP 5: Handle parent (will find existing clone in registry) if (this.parent !== null) { // Parent will already be in registry if we're cloning from root // If cloning subtree, parent might not be cloned cloned.parent = cloneRegistry.get(this.parent) || null; } return cloned; }} // Usageconst root = new TreeNode("root");const child1 = new TreeNode("child1");const child2 = new TreeNode("child2");const grandchild = new TreeNode("grandchild"); root.addChild(child1);root.addChild(child2);child1.addChild(grandchild); // Clone the entire treeconst clonedRoot = root.deepClone(); // Verify complete independenceclonedRoot.value = "cloned-root";clonedRoot.children[0].value = "cloned-child1"; console.log(root.value); // "root" - unchanged ✓console.log(root.children[0].value); // "child1" - unchanged ✓ // Verify structure preservedconsole.log(clonedRoot.children[0].parent === clonedRoot); // true ✓console.log(clonedRoot.children[0].children[0].parent === clonedRoot.children[0]); // true ✓ // Verify no cross-contaminationconsole.log(clonedRoot.children[0].parent === root); // false ✓console.log(clonedRoot !== root); // true ✓Always register an object in the clone map BEFORE recursively cloning its references. If you register after, a circular reference back to the current object will not find it in the registry, causing infinite recursion.
In practice, you often need selective deep copying—deep copying some references while shallow copying or sharing others. This requires explicit design decisions about each field type.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
// Selective deep copy: different strategies for different fields // Shared, immutable configuration (shallow copy OK)class ApplicationConfig { constructor( public readonly appName: string, public readonly version: string, public readonly features: ReadonlyArray<string> ) {}} // Per-user mutable state (MUST deep copy)class UserPreferences { constructor( public theme: string, public fontSize: number, public customSettings: Map<string, string> ) {} clone(): UserPreferences { return new UserPreferences( this.theme, this.fontSize, new Map(this.customSettings) ); }} // Expensive to create, immutable after creation (shallow copy OK)class CompiledTemplate { private compiledAt: Date; constructor( public readonly templateId: string, public readonly compiledHtml: string ) { this.compiledAt = new Date(); }} // Document that combines all strategiesclass UserDocument { constructor( // SHARE: Global immutable config public readonly config: ApplicationConfig, // DEEP COPY: User-specific mutable preferences public preferences: UserPreferences, // SHARE: Expensive immutable template public readonly template: CompiledTemplate, // DEEP COPY: Document's own mutable content public content: string[], // DEEP COPY: Document's own mutable metadata public metadata: Map<string, any> ) {} clone(): UserDocument { return new UserDocument( this.config, // SHARE - immutable this.preferences.clone(), // DEEP COPY - mutable this.template, // SHARE - immutable (expensive) [...this.content], // DEEP COPY - mutable new Map(this.metadata) // DEEP COPY - mutable ); }} // Usageconst globalConfig = new ApplicationConfig("MyApp", "2.0", ["spell-check", "auto-save"]);const template = new CompiledTemplate("invoice", "<html>...</html>"); const doc1 = new UserDocument( globalConfig, new UserPreferences("dark", 14, new Map([["lang", "en"]])), template, ["Paragraph 1", "Paragraph 2"], new Map([["author", "Alice"]])); const doc2 = doc1.clone(); // Modifications to doc2 are independent where neededdoc2.preferences.theme = "light";doc2.content.push("Paragraph 3");doc2.metadata.set("author", "Bob"); console.log(doc1.preferences.theme); // "dark" - unchanged ✓console.log(doc1.content.length); // 2 - unchanged ✓console.log(doc1.metadata.get("author")); // "Alice" - unchanged ✓ // Shared references remain shared (intentionally)console.log(doc1.config === doc2.config); // true - same config ✓console.log(doc1.template === doc2.template); // true - same template ✓Copy-related bugs are notoriously difficult to debug because they manifest as action at a distance—modifying one object causes changes in another with no apparent connection. Here are the most common pitfalls:
{...obj} and [...arr] look like complete copies but are shallow. Nested objects are shared.Object.assign({}, obj) copies only top-level properties by reference.slice(), concat() create new arrays, but elements (if objects) are shared.new Map(existingMap) copies entries, but if values are objects, they're shared.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// Demonstrating common pitfalls // PITFALL 1: Spread operator false securityconst original = { name: "Test", settings: { theme: "dark" } // Nested object!}; const copy = { ...original };copy.settings.theme = "light";console.log(original.settings.theme); // "light" - AFFECTED! // FIX: Deep clone nested objectsconst fixedCopy = { ...original, settings: { ...original.settings }}; // PITFALL 2: Array of objectsconst users = [ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }]; const usersCopy = [...users]; // Shallow!usersCopy[0].name = "Alicia";console.log(users[0].name); // "Alicia" - AFFECTED! // FIX: Clone each elementconst usersDeepCopy = users.map(u => ({ ...u })); // PITFALL 3: Matrix (array of arrays)const matrix = [[1, 2], [3, 4]];const matrixCopy = [...matrix]; // Shallow! matrixCopy[0][0] = 99;console.log(matrix[0][0]); // 99 - AFFECTED! // FIX: Clone each rowconst matrixDeepCopy = matrix.map(row => [...row]); // PITFALL 4: Map with object valuesconst cache = new Map<string, {data: string}>();cache.set("key1", { data: "value1" }); const cacheCopy = new Map(cache); // Shallow!cacheCopy.get("key1")!.data = "modified";console.log(cache.get("key1")!.data); // "modified" - AFFECTED! // FIX: Clone each valueconst cacheDeepCopy = new Map( Array.from(cache.entries()).map( ([key, value]) => [key, { ...value }] )); // PITFALL 5: Class instance without cloneclass Widget { constructor(public config: { size: number }) {}} const widget = new Widget({ size: 100 });const widgetRef = widget; // This is NOT a copy!widgetRef.config.size = 200;console.log(widget.config.size); // 200 - Same object! // Even "copying" primitively doesn't workconst widgetCopy = Object.assign({}, widget); // Loses prototype!console.log(widgetCopy instanceof Widget); // false!When you suspect shared-state bugs: (1) Add identity checks: console.log(copy.field === original.field) for each reference field. (2) Use Object.freeze() on originals during testing—modifications will throw errors. (3) Log object IDs to trace which objects are actually shared.
The choice between shallow and deep copying is one of the most consequential decisions in implementing the Prototype Pattern. Let's consolidate the key principles:
What's next:\n\nWith solid understanding of cloning mechanics, we're ready to examine prototype registries—centralized stores of prototype objects that enable runtime-configurable object creation. The next page explores how registries make the Prototype Pattern practical for large-scale systems.
You now understand the critical distinction between shallow and deep copying, when each approach is appropriate, and how to implement both correctly. Next, we'll explore prototype registries for managing collections of prototypes.