Manipulating Objects in JavaScript

Manipulating Objects in JavaScript

Objects are the backbone of almost everything I build in JavaScript — even arrays and functions are technically objects under the hood. Understanding how to create, modify, copy, and inspect objects properly took my code from “it works” to “it’s actually maintainable and bug-free.” In this article, I’ll focus specifically on manipulating objects: creating them, changing their shape, copying them safely, and understanding what’s really happening in memory.

Creating Objects

Object Literals

const user = {
  name: "Alice",
  age: 30,
  isActive: true,
};

This is what I use for the vast majority of everyday objects — simple, readable, and immediate.

Object Constructors and Object.create()

const emptyObj = new Object(); // rarely used directly, object literals are preferred

const proto = { greet() { return `Hi, I'm ${this.name}`; } };
const person = Object.create(proto);
person.name = "Bob";
console.log(person.greet()); // Hi, I'm Bob

Object.create() lets me explicitly set an object’s prototype at creation time, which is useful when I want fine-grained control over the prototype chain without using class syntax.

Factory Functions

function createUser(name, age) {
  return {
    name,
    age,
    greet() {
      return `Hello, I'm ${name}`;
    },
  };
}

const alice = createUser("Alice", 30);

I reach for factory functions when I want object creation logic without the overhead (and this-binding quirks) of classes.

Adding, Updating, and Deleting Properties

const product = { name: "Laptop", price: 1200 };

product.inStock = true;          // add
product["price"] = 1100;         // update, bracket notation
delete product.inStock;          // remove

console.log(product); // { name: "Laptop", price: 1100 }

Dot Notation vs. Bracket Notation

const key = "price";
console.log(product.price);   // 1100 — dot notation, key known ahead of time
console.log(product[key]);    // 1100 — bracket notation, key can be dynamic

I use bracket notation whenever the property name is stored in a variable or computed at runtime — dot notation only works with literal, static identifiers.

Computed Property Names

function createSetting(key, value) {
  return {
    [key]: value, // the property name itself is computed from a variable
  };
}

console.log(createSetting("theme", "dark")); // { theme: "dark" }

Checking for Property Existence

const user = { name: "Alice", age: undefined };

console.log("age" in user);              // true — key exists, even though value is undefined
console.log(user.hasOwnProperty("age"));  // true
console.log(Object.hasOwn(user, "age"));  // true — modern, recommended alternative
console.log(user.age !== undefined);      // false — misleading! don't rely on this alone

I always use Object.hasOwn() (or hasOwnProperty() in older codebases) instead of just checking !== undefined, because a property can genuinely exist with a value of undefined, which the simple comparison would miss.

Copying Objects

Shallow Copy

const original = { name: "Alice", address: { city: "Lahore" } };

const copy1 = { ...original };
const copy2 = Object.assign({}, original);

copy1.name = "Bob";
console.log(original.name); // Alice — top-level properties are independent

copy1.address.city = "Karachi";
console.log(original.address.city); // Karachi — nested objects are STILL shared!

This is the single most important thing to internalize about copying objects in JavaScript: spread and Object.assign() only copy one level deep. Nested objects and arrays inside are copied by reference, not by value.

Deep Copy

const deepCopy = structuredClone(original);
deepCopy.address.city = "Islamabad";
console.log(original.address.city); // Karachi — unaffected this time

structuredClone() is a built-in, modern way to create true deep copies without needing a library or the old JSON.parse(JSON.stringify(obj)) trick (which fails on things like Date, Map, Set, functions, and circular references).

// The old trick and why it's risky:
const data = { date: new Date(), fn: () => {} };
const broken = JSON.parse(JSON.stringify(data));
console.log(broken.date);  // a STRING, not a Date object anymore
console.log(broken.fn);    // undefined — functions are silently dropped

Merging Objects

const defaults = { theme: "light", fontSize: 14, showSidebar: true };
const userPrefs = { fontSize: 18 };

const merged = { ...defaults, ...userPrefs };
console.log(merged); // { theme: "light", fontSize: 18, showSidebar: true }

For deep merging (combining nested objects rather than overwriting them entirely), I write a small recursive helper or use a library like Lodash’s merge(), since neither spread nor Object.assign() merge nested objects — they simply overwrite them.

function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (source[key] instanceof Object && key in target) {
      Object.assign(source[key], deepMerge(target[key], source[key]));
    }
  }
  return { ...target, ...source };
}

Immutability Patterns

const config = Object.freeze({ apiUrl: "https://api.example.com", retries: 3 });

config.retries = 10; // fails silently (or throws in strict mode)
console.log(config.retries); // 3

console.log(Object.isFrozen(config)); // true

For creating a modified copy instead of mutating a frozen object, I combine spread with the new values:

const updatedConfig = { ...config, retries: 10 };
console.log(updatedConfig.retries); // 10
console.log(config.retries);        // 3 — original still untouched

This “create a new object instead of mutating” pattern is the foundation of state management in libraries like Redux, and it’s a habit I now apply broadly, even outside of frameworks that require it.

Object Comparison

const objA = { x: 1 };
const objB = { x: 1 };

console.log(objA === objB); // false — different references in memory, even with identical content
console.log(objA === objA); // true — same reference

Objects are compared by reference, not by structural equality. To compare content, I write (or use a library for) a proper equality check.

function shallowEqual(a, b) {
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  return keysA.every((key) => a[key] === b[key]);
}

console.log(shallowEqual({ x: 1, y: 2 }, { x: 1, y: 2 })); // true

Getters and Setters on Plain Objects

const circle = {
  radius: 5,
  get area() {
    return Math.PI * this.radius ** 2;
  },
  set diameter(value) {
    this.radius = value / 2;
  },
};

console.log(circle.area); // 78.53981633974483
circle.diameter = 20;
console.log(circle.radius); // 10

I use getters/setters on plain objects (not just classes) when I want computed or validated properties without a full class definition.

Internal Working: Objects and the Prototype Chain

Every JavaScript object has an internal [[Prototype]] reference (accessible via Object.getPrototypeOf()), forming a chain the engine walks up when looking up a property that isn’t found directly on the object itself.

const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;

console.log(dog.eats);  // true — found on the prototype, not dog itself
console.log(Object.keys(dog)); // ["barks"] — own properties only, prototype excluded

This is exactly why Object.keys(), Object.values(), and Object.entries() only show barks — they deliberately only include own enumerable properties, ignoring anything inherited through the prototype chain, which keeps everyday object inspection predictable.

Internally, most JavaScript engines (like V8) optimize object property access using hidden classes (sometimes called “shapes”) — objects with the same set of properties, added in the same order, share an internal hidden class, which lets the engine access their properties much faster than if every object had a completely dynamic, unpredictable shape. This is actually a good practical reason to initialize all of an object’s properties in the constructor or factory function upfront, rather than adding them ad hoc later — it keeps the object’s “shape” consistent and helps the engine optimize property access.

Practical, Real-World Patterns

Sanitizing an Object Before Sending to an API

function omit(obj, keysToRemove) {
  const result = { ...obj };
  keysToRemove.forEach((key) => delete result[key]);
  return result;
}

const user = { id: 1, name: "Alice", passwordHash: "abc123" };
console.log(omit(user, ["passwordHash"])); // { id: 1, name: "Alice" }

Updating Nested State Immutably

const state = {
  user: { name: "Alice", settings: { theme: "light" } },
};

const newState = {
  ...state,
  user: {
    ...state.user,
    settings: {
      ...state.user.settings,
      theme: "dark",
    },
  },
};

console.log(newState.user.settings.theme); // dark
console.log(state.user.settings.theme);    // light — original untouched

This nested-spread pattern is verbose, which is exactly why libraries like Immer exist — but understanding the manual version first helped me appreciate what those libraries are actually doing underneath.

Best Practices

  • I always remember that spread/Object.assign() are shallow copies — I reach for structuredClone() when I genuinely need a deep copy.
  • I use Object.hasOwn() rather than !== undefined checks for property existence.
  • I keep object shapes consistent (same properties, same order) where performance in hot code paths matters.
  • I treat state objects as immutable, creating new objects instead of mutating existing ones, especially in UI-driven applications.

Common Mistakes to Avoid

  1. Assuming spread creates a deep copy and being surprised when nested objects are still shared.
  2. Comparing objects with === expecting structural equality.
  3. Mutating a frozen object and not noticing the silent failure (outside strict mode).
  4. Using JSON.parse(JSON.stringify()) for deep cloning and losing Date, Map, Set, or function values.

Debugging Tips

  • I use console.log(JSON.stringify(obj, null, 2)) for a readable, indented view of an object’s structure.
  • I use Object.getPrototypeOf(obj) to inspect an object’s prototype chain when debugging unexpected inherited behavior.
  • For “why did my original object change” bugs, I trace back to a shallow copy where a nested object was mutated by reference.

FAQs

Q: Is Object.assign() a deep or shallow copy? A: Shallow. Nested objects are copied by reference, not duplicated.

Q: What’s the difference between delete obj.key and setting obj.key = undefined? A: delete actually removes the property entirely, so "key" in obj becomes false. Setting it to undefined keeps the key present with an undefined value.

Q: Can I freeze nested objects automatically? A: Not with Object.freeze() alone — you’d need a recursive deep-freeze helper that walks the object and freezes every nested object individually.

Q: Why do two identical-looking objects fail an === comparison? A: Because objects are compared by reference in JavaScript, not by their contents — only if both variables point to the exact same object in memory will === return true.

Summary and Key Takeaways

  • Object literals are the most common way to create objects; Object.create() gives explicit prototype control.
  • Spread and Object.assign() are shallow copy tools — use structuredClone() for genuine deep copies.
  • Object.hasOwn() is the modern, reliable way to check property existence.
  • Objects compare by reference, not by value — write your own equality check when structural comparison is needed.
  • Treating objects as immutable (creating new copies instead of mutating) leads to more predictable, bug-resistant code, especially in UI state management.

Getting genuinely comfortable with how objects behave in memory — not just the syntax for creating and modifying them — has saved me from an enormous number of “why did this change unexpectedly” bugs over the years.

References

Total
0
Shares

Leave a Reply

Previous Post
Working with Arrays in JavaScript

Working with Arrays in JavaScript

Next Post
Array and Object Methods in JavaScript

Array and Object Methods in JavaScript

Related Posts