Shallow and deep copies
Photocopying a form that has a sticky note on it gives you a picture of the sticky note, not a new sticky note. That’s fine for paper. In code, a shallow copy is worse than a photocopy: the new object gets its own top-level fields, but anything nested (a list, another object) is the same nested thing, shared by the original and the copy. Change the list through the copy, and the original’s list changes, because there is only one list.
A deep copy also copies the nested things, so the original and the copy have nothing in common and can change independently.
Prototype specifies the kinds of objects to create using a prototypical instance and creates new objects by copying it. The interesting decisions are about copy depth and identity: which fields are copied by value, which nested objects are cloned, which are deliberately shared (immutable data, references to other aggregates), and which must be reset (ids, timestamps, status). Language defaults are almost all shallow: Kotlin data class copy(), TypeScript object spread and Object.assign, Java Object.clone(). Swift structs are the exception that proves the rule: copying is deep exactly as far as the fields are value types, and stops at the first class reference.
// Swift: with a class for line items, a struct copy still shares them
final class LineItem {
var description: String
var quantity: Int
init(description: String, quantity: Int) { self.description = description; self.quantity = quantity }
}
struct Invoice {
var id: UUID
var client: String
var items: [LineItem] // an array of references
}
var copy = original // copies the array, not the LineItem objects
copy.items[0].quantity = 5 // original.items[0].quantity is 5 too
// Fix: make LineItem a struct. Now the copy is fully independent
struct LineItemValue { var description: String; var quantity: Int }data class LineItem(var description: String, var quantity: Int)
data class Invoice(val id: UUID, val client: String, val items: MutableList<LineItem>)
val copy = original.copy() // shallow: the same MutableList, the same LineItems
copy.items[0].quantity = 5 // original.items[0].quantity is 5 too
copy.items += LineItem("Extra", 1) // the original gets the extra line too
// Fix: immutable data, so sharing is safe
data class LineItemV2(val description: String, val quantity: Int)
data class InvoiceV2(val id: UUID, val client: String, val items: List<LineItemV2>)public final class Invoice {
UUID id; String client; List<LineItem> items;
// Shallow: both invoices hold the same list object
Invoice shallowCopy() {
var c = new Invoice(); c.id = id; c.client = client; c.items = items; return c;
}
}
// Fix: records and immutable lists, so sharing is safe
public record LineItem(String description, int quantity) {}
public record InvoiceV2(UUID id, String client, List<LineItem> items) {
public InvoiceV2 { items = List.copyOf(items); } // defensive, unmodifiable copy
}interface LineItem { description: string; quantity: number }
interface Invoice { id: string; client: string; items: LineItem[] }
const copy = { ...original }; // shallow: the same items array, the same objects
copy.items[0].quantity = 5; // original.items[0].quantity is 5 too
copy.items.push({ description: "Extra", quantity: 1 }); // and the original gets it
// Fix: copy the nested parts you will change, or use structuredClone for plain data
const deep = { ...original, items: original.items.map((i) => ({ ...i })) };
const alsoDeep = structuredClone(original);- The copy looks independent: it has its own variable, and its top-level fields can be changed safely.
- But
itemsis a reference: both invoices point to the same list of the same line items. - The simplest fix is immutable data: if line items can’t be changed in place, sharing them is harmless, and “changing” one means replacing it with a new one in the copy’s own list.
- When the data must be mutable, copy the nested parts explicitly.
Tap the line with the bug.
struct Invoice {
var id: UUID
var number: String
var client: String
var items: [LineItem]
var status: Status
}
func duplicate(_ original: Invoice) -> Invoice {
var copy = original
copy.status = .draft
return copy
}data class Invoice(
val id: UUID,
val number: String,
val client: String,
val items: List<LineItem>,
val status: Status,
)
fun duplicate(original: Invoice): Invoice =
original.copy(status = Status.DRAFT)public record Invoice(
UUID id,
String number,
String client,
List<LineItem> items,
Status status) {
public Invoice duplicate() {
return new Invoice(id, number, client, items, Status.DRAFT);
}
}interface Invoice {
id: string;
number: string;
client: string;
items: readonly LineItem[];
status: Status;
}
function duplicate(original: Invoice): Invoice {
return { ...original, status: "draft" };
}Hint: The items are immutable here, so sharing them is fine. What else did the copy keep that should never be the same in two invoices?
The copy keeps the original’s id (and its invoice number). When the user saves the duplicate, the repository does an upsert by id and overwrites the original invoice with the new one, which looks to the user like the original disappeared. Even without the overwrite, two invoices with the same number is a legal problem in most countries.
A prototype copy is a new object: give it a new identity and reset everything that describes the original’s history (number, dates, status, payment links):
func duplicate(_ original: Invoice, numbers: InvoiceNumbers) -> Invoice {
var copy = original
copy.id = UUID()
copy.number = numbers.next()
copy.status = .draft
return copy
}fun duplicate(original: Invoice, numbers: InvoiceNumbers): Invoice =
original.copy(id = UUID.randomUUID(), number = numbers.next(), status = Status.DRAFT)public Invoice duplicate(InvoiceNumbers numbers) {
return new Invoice(UUID.randomUUID(), numbers.next(), client, items, Status.DRAFT);
}function duplicate(original: Invoice, numbers: InvoiceNumbers): Invoice {
return { ...original, id: crypto.randomUUID(), number: numbers.next(), status: "draft" };
}Notice that the fix still shares items: they’re immutable, so sharing is safe and cheap.
Predict the output
Swift: Invoice is a struct, and LineItem is now also a struct. You write var copy = original, then copy.items[0].quantity = 5. What is original.items[0].quantity?
With value types all the way down, var copy = original gives the copy its own array of its own line items (Swift copies the array storage lazily, the moment one side changes it, which is called copy-on-write). The original is unchanged. That's why Swift code rarely needs an explicit deep-copy method: it's what assignment already does, as long as no field is a class.
- Prototype creates objects by copying an existing instance; the real decisions are copy depth and identity.
- Defaults are shallow: Kotlin
copy(), TS spread/Object.assign, Javaclone(). Swift structs copy deeply up to the first class reference (copy-on-write for collections). - Prefer immutable nested data so sharing is safe; deep-copy only what will be mutated (
structuredClonefor plain data in TS). - Reset identity and history on copy: id, number, timestamps, status, external references.
Check this part
After a shallow copy, what do the original and the copy share?
Why is sharing nested data safe when it is immutable?
Which of these is a deep copy by default?