Deep-extending objects in JavaScript

Posted in Articles, JavaScript

Today I’m going to be talking about Object.extend without much introduction or context. Bear with me. This is a prerequisite blog post for something I’ll be talking about in a few days.

Extending objects in JavaScript

Prototype has a function named Object.extend. It takes two objects and copies all properties from the second object onto the first. This is what it looks like, more or less:

Object.extend = function(destination, source) {
  for (var property in source)
    destination[property] = source[property];
  return destination;
};

One major advantage of this approach is that it modifies only the destination object and leaves the source object untouched. Except… not really, in certain complex cases.

For example…

Consider this:

var destination = { foo: "bar", baz: { bang: "zoom" } };
var source = { baz: { thud: "narf" } };

Object.extend(destination, source);

destination.baz.thud; // -> "narf"
destination.baz.bang; // -> undefined

source.baz.thud = "troz";
destination.baz.thud; // -> "troz"

See the problem? I assigned destination.baz to point to the same thing as source.baz — but that thing is an object, and objects are copied by reference. So the existing destination.baz property — an object with its own bang key — gets replaced entirely. Plus, when I redefine the destination.baz.thud property, I’m also redefining source.baz.thud.

In other words, Object.extend makes a “shallow” copy of source, rather than a “deep” copy.

The fix

Let’s take a stab at a “deep extend” function. We want it to work much like Object.extend, but behave a bit differently when it encounters an object.

Object.deepExtend = function(destination, source) {
  for (var property in source) {
    if (typeof source[property] === "object") {
      destination[property] = destination[property] || {};
      arguments.callee(destination[property], source[property]);
    } else {
      destination[property] = source[property];
    }
  }
  return destination;
};

Remember that arguments.callee refers to the function itself. In other words, recursion is the magic ingredient to turn a shallow copy into a deep copy.

Wait! We’re not done, you lazy bastards. This simple function will cause some unexpected bugs.

The first one is simple to fix. One of the quirks of JavaScript is that typeof null returns "object". But it’s not an object. So let’s check for that, too:

Object.deepExtend = function(destination, source) {
  for (var property in source) {
    if (typeof source[property] === "object" &&
     source[property] !== null ) {
      destination[property] = destination[property] || {};
      arguments.callee(destination[property], source[property]);
    } else {
      destination[property] = source[property];
    }
  }
  return destination;
};

The second one is a little trickier. If you don’t see it now, you’d probably see it the first time you tried something like this:

Object.deepExtend(options, { sourceElement: $('names_list') });

Yes, that property value is a DOM node. Try typeof document.body in your Firebug console and you’ll see it returns “object” instead of a far more useful value. Copying all the individual properties of a DOM node onto another object would be like gluing a bunch of feathers onto a throw pillow and calling it a chicken.

DOM nodes should always be passed by reference, even when doing a deep extend. In fact, the only type of object that should not be passed by reference is a plain‐old JavaScript object. But how do we detect a POJO? Keep in mind:

  1. As explained, host objects (like DOM nodes) identify themselves as objects.

  2. Anything created with the new keyword (e.g., an instance of a Prototype class) identifies itself as an object.

  3. Arrays (and the arguments collection) identify themselves as objects.

For these reasons, the typeof operator won’t work, nor will Juriy’s clever approach. Instead we’ll have to check the constructor.

Object.deepExtend = function(destination, source) {
  for (var property in source) {
    if (source[property] && source[property].constructor &&
     source[property].constructor === Object) {
      destination[property] = destination[property] || {};
      arguments.callee(destination[property], source[property]);
    } else {
      destination[property] = source[property];
    }
  }
  return destination;
};

Does it work? Let’s try the example from earlier.

var destination = { foo: "bar", baz: { bang: "zoom" } };
var source = { baz: { thud: "narf" } };

Object.extend(destination, source);

destination.baz.thud; // -> "narf"
destination.baz.bang; // -> "zoom"

source.baz.thud = "troz";
destination.baz.thud; // -> "narf"

In the real world, we write unit tests. But that’s the boring part, so it’s left as an exercise for the reader. (Suckers.)

The tease

As I said, this isn’t the real blog post. The real blog post will be here in a few days. But I’m getting this part out of the way so that it doesn’t clutter up the rest.

Comments

  1. Pingback