M. David Green

Existence is a byproduct of semantics

Refactoring Javascript: Extending a Base Type

Javascript allows a developer to modify the basic object types, such as String, that are shared across the entire codebase. This ability is not limited to newly defined constructors; even the Javascript Object constructor can be extended. While this flexibility may seem convenient, it is generally a bad practice because it can create conflicts and inconsistent behavior across libraries when shared types are modified.

For example

Given a Person constructor with first_name and last_name attributes:

function Person(first_name, last_name) {
    this.full_name = function() {
        var full_name = [];
        full_name.push(first_name.capitalize());
        full_name.push(last_name.capitalize());
        return full_name.join(" ");
    };
}
String.prototype.capitalize = function() {
    var capitalized = [];
    capitalized.push(this.charAt(0).toUpperCase());
    capitalized.push(this.slice(1).toLowerCase());
    return capitalized.join("");
};

Which might be called like this:

var person = new Person("DAVID", "GREEN");
person.full_name() // "David Green"

Javascript String objects are created silently whenever quotes are applied to a set of characters. String objects carry a small set of useful methods, but capitalizing the first letter of the String instance’s value is not one of these. Here we’re modifying the String prototype to add this functionality, so it can be called anywhere a String is instantiated.

A Jasmine test to make sure this is working might look like this:

describe("Example", function() {
    beforeEach(function() {
        person = new Person("DAVID","GREEN");
    });
    describe("a person", function() {
        it("should have a full name", function() {
            expect(person.full_name()).toEqual('David Green');
        });
    });
})

Considerations:

The core issue with modifying a base type such as String is that it is very likely that String objects are used elsewhere in the code of your application. This means that the change you are making to solve this local problem has the potential to affect any reference to the String constructor or String instances. That is poor encapsulation.

Adding a new method to the prototype may seem safe, since no other code should make use of that method unless it is aware that the method exists. But there are cases in which more than one developer may choose to implement the same new method in a different way. Even the same developer, approaching a slightly different problem, may find different appropriate ways to implement a new method such as capitalize. Javascript is very accommodating, and will simply accept the last version defined as the one to be applied whenever the new method is invoked.

If your code relies on any libraries, the situation is more risky. Some libraries base their functionality on extending base objects, but ironically they may depend on those base objects being otherwise vanilla.

A safer approach is to create a new type that extends an existing base type, and cast your local instances to be instances of the new type. That way you can add any methods you like to the prototype of your new object type, and leave the original base type unmodified.

The highlighted code above could be refactored into:

function Person(first_name, last_name) {
    var first_name = new Extended_String(first_name),
        last_name = new Extended_String(last_name);
    this.full_name = function() {
        var full_name = [];
        full_name.push(first_name.capitalize());
        full_name.push(last_name.capitalize());
        return full_name.join(" ");
    };
}
function Extended_String(text) {
    this.value = text || '';
    this.length = this.value.length;
    this.toString = this.valueOf = function() {
        return this.value;
    };
}
Extended_String.prototype = new String;
Object.defineProperty(
    Extended_String.prototype
    , 'capitalize'
    , { value: function() {
            var capitalized = [];
            capitalized.push(this.charAt(0).toUpperCase());
            capitalized.push(this.slice(1).toLowerCase());
            return capitalized.join("");
        }
        , enumerable: false
    }
);

By following these steps:

  1. Identify a base Javascript type that is being modified with new functionality globally to address a local need.
  2. If you are using test-driven development, this is when you would define your first test parameters with an expected return value, and create your first test.
  3. Create a new extended version of the base type, implementing the core functionality of the base type.
  4. Assign a new instance of the base type to the prototype of your new extended version of the base type.
  5. Add your new method to the new extended constructor by using Object.defineProperty.
  6. Pass a value of false to the enumerable argument if you don’t want for/in loops to include this method when iterating over the properties of instances of the new type.
  7. Create temporary variables in your original function that assign the value you need to manipulate to new instances of your new type.
  8. Make sure your code works as expected using the temporary variables in place of the original instances of the modified base type.
  9. If everything is working, remove your modifications to the original base type and test again.

The final code is both longer and more complicated than the original code. However it provides more robust encapsulation of functionality. There are code examples and libraries available that provide shortcuts to extending object types in a clean and robust manner. The important thing is to recognize the dangers of extending base types, and to know how to avoid that.