Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 014 / 100

Objects and references: a practical guide for developers

Copying objects is harder than it looks.

Expona Admin7 min read

I spent three hours debugging a production bug where modifying an order object in one place silently corrupted data everywhere else. The root cause was deceptively simple: I didn't understand how objects and references work in JavaScript. This article covers what I learned, and what will save you from the same trap.

Objects are references

When you assign an object to a variable in JavaScript, you're not copying the data itself. You're copying a reference—a pointer to where that data lives in memory. This is the single most important concept to understand when working with objects, and it's the source of more bugs than you'd expect.

Let me show you with a concrete example from a real e-commerce system I built. Imagine we're processing an order:

typescript
const order = {
  id: 'ORD-001',
  customer: 'John',
  total: 150,
  items: [
    { sku: 'ITEM-A', qty: 2, price: 50 },
    { sku: 'ITEM-B', qty: 1, price: 50 }
  ]
};

const processedOrder = order;
processedOrder.total = 200;

What's the value of order.total now? It's 200, not 150. Both variables point to the same object in memory. When you modify processedOrder, you're modifying the original order. This is reference semantics, and it's fundamentally different from primitives like numbers and strings, which copy by value.

In my ride-hailing platform, this caused a nightmare: a payment calculation function modified the ride object, thinking it was working with a copy. The original ride in the database got corrupted, cascading failures downstream. Reference semantics is not a bug—it's how JavaScript works—but if you don't respect it, you'll create shared-mutation bugs that are incredibly hard to trace.

Shallow copies

Once you understand that assignment creates a reference, your first instinct is to use the spread operator or Object.assign to make a copy. These tools are convenient, but they only copy one level deep—the top-level keys. Any nested objects or arrays still point to the original data.

Here's where it gets tricky:

typescript
const original = {
  id: 'ORD-001',
  customer: { name: 'John', email: 'john@example.com' },
  items: [
    { sku: 'A', qty: 2 }
  ]
};

// Shallow copy using spread
const shallowCopy = { ...original };

// Modifying top-level keys doesn't affect original
shallowCopy.id = 'ORD-002'; // original.id stays 'ORD-001'

// But nested objects still reference the same memory
shallowCopy.customer.name = 'Jane'; // original.customer.name is now 'Jane'
shallowCopy.items[0].qty = 5; // original.items[0].qty is now 5

The spread operator is a shallow copy. It creates a new object with new top-level keys, but those keys point to the same nested objects as the original. In my CRM system, I used shallow copies for user profiles and wasted hours wondering why notifications were being sent to the wrong email address—I'd modified the nested customer object without realizing it was shared.

Object.assign behaves identically:

typescript
const shallowCopy2 = Object.assign({}, original);
shallowCopy2.items[0].qty = 10; // original affected again

Shallow copies are useful when you know your data structure is flat or when you're intentionally sharing nested references. But for most real-world objects—orders with line items, users with preferences, configs with nested settings—shallow copies create silent bugs.

Deep copies

A deep copy recursively copies every level, including nested objects and arrays. Modern JavaScript gives us structuredClone, which is the cleanest solution for most use cases.

typescript
const original = {
  id: 'ORD-001',
  customer: { name: 'John', email: 'john@example.com' },
  items: [
    { sku: 'ITEM-A', qty: 2, price: 50 },
    { sku: 'ITEM-B', qty: 1, price: 75 }
  ],
  createdAt: new Date('2024-01-15'),
  metadata: new Map([['source', 'web']])
};

const deepCopy = structuredClone(original);

// Modify the copy at any depth
deepCopy.customer.name = 'Jane';
deepCopy.items[0].qty = 5;
deepCopy.metadata.set('source', 'mobile');

// Original is completely untouched
console.log(original.customer.name); // 'John'
console.log(original.items[0].qty); // 2
console.log(original.metadata.get('source')); // 'web'

structuredClone handles complex types correctly: it clones Date objects, Map and Set, Uint8Array, and more. This is why it's been a game-changer for me. In my travel booking platform, I use it whenever I need to create an independent copy of a booking object for processing or comparison.

There's an older pattern using JSON.parse(JSON.stringify()), but avoid it. It's slower, and it breaks with Date, Map, functions, and circular references:

typescript
// Don't do this
const buggyClone = JSON.parse(JSON.stringify(original));
console.log(buggyClone.createdAt instanceof Date); // false—it's a string now
console.log(buggyClone.metadata); // null—Map is lost

Use structuredClone for deep copies. It's available in modern Node.js (v17+) and all modern browsers.

Immutability

Understanding references and copies leads naturally to immutability: the practice of treating data as unchangeable. Instead of modifying an object in place, you create a new version with the changes you want. This pattern prevents entire categories of bugs.

Here's a practical example from a SaaS settings panel I built:

typescript
// Mutable approach (error-prone)
const settings = { theme: 'light', notifications: true, timezone: 'UTC' };

function toggleNotifications(settings) {
  settings.notifications = !settings.notifications; // modifies original
  return settings;
}

const updated = toggleNotifications(settings);
// Now settings and updated point to the same object
// Any other code holding a reference to settings sees the change

Compare this with an immutable approach:

typescript
const settings = { theme: 'light', notifications: true, timezone: 'UTC' };

function toggleNotifications(settings) {
  return { ...settings, notifications: !settings.notifications };
}

const updated = toggleNotifications(settings);
// settings is unchanged, updated is a new object
// Any code with the original settings sees no change

In the immutable version, you're explicit about creating a new object. The original is untouched. This is safer because:

  • Debugging is easier—you know which function created a change because it returns a new object.
  • Concurrent code can't be surprised by mutations. If two async operations both receive the same settings object, one can't accidentally modify it mid-operation.
  • UI frameworks like React detect changes by reference equality. If you return a new object, React knows something changed.
  • Undo/redo becomes trivial—you just keep a history of versions.

I'm not saying you must be immutable everywhere—that's overkill and hurts performance. But for shared state, API responses, and Redux/Zustand stores, immutability prevents entire classes of bugs.

Common mistakes

  • Assuming spread copies nested data. It doesn't. If you need a deep copy, use structuredClone.
  • Modifying function parameters. If a function receives an object and modifies it, callers will see the change. Document this clearly or return a new object instead.
  • Forgetting that arrays are objects. const arr2 = arr1 copies a reference, not the array. Use const arr2 = [...arr1] for a shallow copy or structuredClone(arr1) for a deep copy.
  • Using JSON.parse(JSON.stringify()) for deep copies. It loses types like Date and Map. Use structuredClone.
  • Mixing immutable and mutable patterns. Pick a convention and stick to it. Inconsistency breeds bugs.

Key takeaways

  • Objects and references are the same thing. Assignment doesn't copy data; it copies a reference.
  • Shallow copies (spread, Object.assign) copy the top level but share nested objects. Use them only for flat structures.
  • structuredClone creates a true deep copy that handles Date, Map, Set, and other complex types correctly.
  • Treat shared state as immutable. Return new objects instead of modifying originals to prevent shared-mutation bugs.
  • When in doubt, create a deep copy using structuredClone. The performance cost is negligible compared to the debugging cost of a reference bug.
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