Augmenting JavaScript Core Objects

    Jeff Friesen
    Share

    JavaScript defines several objects that are part of its core: Array, Boolean, Date, Function, Math, Number, RegExp, and String. Each object extends Object, inheriting and defining its own properties and methods. I’ve occasionally needed to augment these core objects with new properties and methods and have created a library with these enhancements. In this article, I present various enhancements that I’ve introduced to the Array, Boolean, Date, Math, Number, and String objects.

    I add new properties directly to the core object. For example, if I needed a Math constant for the square root of 3, I’d specify Math.SQRT3 = 1.732050807;. To add a new method, I first determine whether the method associates with a core object (object method) or with object instances (instance method). If it associates with an object, I add it directly to the object (e.g., Math.factorial = function(n) { ... }). If it associates with object instances, I add it to the object’s prototype (e.g., Number.prototype.abs = function() { ... }).

    Methods and Keyword this

    Within an object method, this refers to the object itself. Within an instance method, this refers to the object instance. For example, in " remove leading and trailing whitespace ".trim(), this refers to the " remove leading and trailing whitespace " instance of the String object in String‘s trim() method.

    Name Collisions

    You should be cautious with augmentation because of the possibility for name collisions. For example, suppose a factorial() method whose implementation differs from (and is possibly more performant than) your factorial() method is added to Math in the future. You probably wouldn’t want to clobber the new factorial() method. The solution to this problem is to always test a core object for the existence of a same-named method before adding the method. The following code fragment presents a demonstration:

    if (Math.factorial === undefined)
       Math.factorial = function(n)
                        {
                           // implementation
                        }
    alert(Math.factorial(6));

    Of course, this solution isn’t foolproof. A method could be added whose parameter list differs from your method’s parameter list. To be absolutely sure that you won’t run into any problems, add a unique prefix to your method name. For example, you could specify your reversed Internet domain name. Because my domain name is tutortutor.ca, I would specify Math.ca_tutortutor_factorial. Although this is a cumbersome solution, it should give some peace of mind to those who are worried about name conflicts.

    Augmenting Array

    The Array object makes it possible to create and manipulate arrays. Two methods that would make this object more useful are equals(), which compares two arrays for equality, and fill(), which initializes each array element to a specified value.

    Implementing and Testing equals()

    The following code fragment presents the implemention of an equals() method, which shallowly compares two arrays — it doesn’t handle the case of nested arrays:

    Array.prototype.equals =
       function(array)
       {
          if (this === array)
             return true;
    
          if (array === null || array === undefined)
             return false;
    
          array = [].concat(array); // make sure this is an array
    
          if (this.length != array.length)
             return false;
    
          for (var i = 0; i < this.length; ++i) 
             if (this[i] !== array[i]) 
                return false;
          return true;
       };

    equals() is called with an array argument. If the current array and array refer to the same array (=== avoids type conversion; the types must be the same to be equal), this method returns true.

    equals() next checks array for null or undefined. When either value is passed, this method returns false. Assuming that array contains neither value, equals() ensures that it’s dealing with an array by concatenating array to an empty array.

    equals() compares the array lengths, returning false when these lengths differ. It then compares each array element via !== (to avoid type conversion), returning false when there’s a mismatch. At this point, the arrays are considered equal and true returns.

    As always, it’s essential to test code. The following test cases exercise the equals() method, testing the various possibilities:

    var array = [1, 2];
    alert("array.equals(array): " + array.equals(array));
    
    alert("['A', 'B'].equals(null): " + ['A', 'B'].equals(null));
    alert("['A', 'B'].equals(undefined): " + ['A', 'B'].equals(undefined));
    
    alert("[1].equals(4.5): " + [1].equals(4.5));
    
    alert("[1].equals([1, 2]): " + [1].equals([1, 2]));
    
    var array1 = [1, 2, 3, 'X', false];
    var array2 = [1, 2, 3, 'X', false];
    var array3 = [3, 2, 1, 'X', false];
    alert("array1.equals(array2): " + array1.equals(array2));
    alert("array1.equals(array3): " + array1.equals(array3));

    When you run these test cases, you should observe the following output (via alert dialog boxes):

    array.equals(array): true
    ['A', 'B'].equals(null): false
    ['A', 'B'].equals(undefined): false
    [1].equals(4.5): false
    [1].equals([1, 2]): false
    array1.equals(array2): true
    array1.equals(array3): false

    Implementing and Testing fill()

    The following code fragment presents the implementation of a fill() method, which fills all elements of the array on which this method is called with the same value:

    Array.prototype.fill =
       function(item)
       {
          if (item === null || item === undefined)
             throw "illegal argument: " + item;
    
          var array = this;
          for (var i = 0; i < array.length; i++)
             array[i] = item;
          return array;
       };

    fill() is called with an item argument. If null or undefined is passed, this method throws an exception that identifies either value. (You might prefer to fill the array with null or undefined.) Otherwise, it populates the entire array with item and returns the array.

    I’ve created the following test cases to test this method:

    try
    {
       var array = [0];
       array.fill(null);
    }
    catch (err)
    {
       alert("cannot fill array with null");
    }
    
    try
    {
       var array = [0];
       array.fill(undefined);
    }
    catch (err)
    {
       alert("cannot fill array with undefined");
    }
    
    var array = [];
    array.length = 10;
    array.fill('X');
    alert("array = " + array);
    
    alert("[].fill(10) = " + [].fill(10));

    When you run these test cases, you should observe the following output:

    cannot fill array with null
    cannot fill array with undefined
    array = X,X,X,X,X,X,X,X,X,X
    [].fill(10) = 

    Augmenting Boolean

    The Boolean object is an object wrapper for Boolean true/false values. I’ve added a parse() method to this object to facilitate parsing strings into true/false values. The following code fragment presents this method:

    Boolean.parse =
       function(s)
       {
          if (typeof s != "string" || s == "")
             return false;
    
          s = s.toLowerCase();
          if (s == "true" || s == "yes")
             return true;
          return false;
       };

    This method returns false for any argument that is not a string, for the empty string, and for any value other than "true" (case doesn’t matter) or "yes" (case doesn’t matter). It returns true for these two possibilities.

    The following test cases exercise this method:

    alert(Boolean.parse(null));
    alert(Boolean.parse(undefined));
    alert(Boolean.parse(4.5));
    alert(Boolean.parse(""));
    alert(Boolean.parse("yEs"));
    alert(Boolean.parse("TRUE"));
    alert(Boolean.parse("no"));
    alert(Boolean.parse("false"));

    When you run these test cases, you should observe the following output:

    false
    false
    false
    false
    true
    true
    false
    false

    Augmenting Date

    The Date object describes a single moment in time based on a time value that’s the number of milliseconds since January 1, 1970 UTC. I’ve added object and instance isLeap() methods to this object that determine if a specific date occurs in a leap year.

    Implementing and Testing an isLeap() Object Method

    The following code fragment presents the implementation of an isLeap() object method, which determines if its date argument represents a leap year:

    Date.isLeap =
       function(date)
       {
          if (Object.prototype.toString.call(date) != '[object Date]')
             throw "illegal argument: " + date;
    
          var year = date.getFullYear();
          return (year % 400 == 0) || (year % 4 == 0 && year % 100 != 0);
       };

    Instead of using a date instanceof Date expression to determine if the date argument is of type Date, this method employs the more reliable Object.prototype.toString.call(date) != '[object Date]' expression to check the type — date instanceof Date would return false when date originated from another window. When a non-Date argument is detected, an exception is thrown that identifies the argument.

    After invoking Date‘s getFullYear() method to extract the four-digit year from the date, isLeap() determines if this year is a leap year or not, returning true for a leap year. A year is a leap year when it’s divisible by 400 or is divisible by 4 but not divisible by 100.

    The following test cases exercise this method:

    try
    {
       alert(Date.isLeap(null));
    }
    catch (err)
    {
       alert("null dates not supported.");
    }
    
    try
    {
       alert(Date.isLeap(undefined));
    }
    catch (err)
    {
       alert("undefined dates not supported.");
    }
    
    try
    {
       alert(Date.isLeap("ABC"));
    }
    catch (err)
    {
       alert("String dates not supported.");
    }
    
    var date = new Date();
    alert(date + (Date.isLeap(date) ? " does " : " doesn't ") +
          "represent a leap year.");

    When you run these test cases, you should observe output that’s similar to the following:

    null dates not supported.
    undefined dates not supported.
    String dates not supported.
    Wed Oct 23 2013 19:30:24 GMT-0500 (Central Standard Time) doesn't represent a leap year.

    Implementing and Testing an isLeap() Instance Method

    The following code fragment presents the implemention of an isLeap() instance method, which determines if the current Date instance represents a leap year:

    Date.prototype.isLeap = 
       function()
       {
          var year = this.getFullYear();
          return (year % 400 == 0) || (year % 4 == 0 && year % 100 != 0);
       };

    This version of the isLeap() method is similar to its predecessor but doesn’t take a date argument. Instead, it operates on the current Date instance, which is represented by this.

    The following test cases exercise this method:

    date = new Date(2012, 0, 1);
    alert(date + ((date.isLeap()) ? " does " : " doesn't ") + 
          "represent a leap year.");
    date = new Date(2013, 0, 1);
    alert(date + ((date.isLeap()) ? " does " : " doesn't ") + 
          "represent a leap year.");

    When you run these test cases, you should observe output that’s similar to the following:

    Sun Jan 01 2012 00:00:00 GMT-0600 (Central Daylight Time) does represent a leap year.
    Tue Jan 01 2013 00:00:00 GMT-0600 (Central Daylight Time) doesn't represent a leap year.

    Augmenting Math

    The Math object declares math-oriented object properties and methods and cannot be instantiated. I’ve added a GOLDEN_RATIO object property and rnd(), toDegrees(), toRadians(), and trunc() object methods to Math.

    About the Golden Ratio

    The Golden Ratio is a math constant that frequently appears in geometry. Two quantities are in the golden ratio when their ratio equals the ratio of their sum to the larger of the two quantities. In other words, for a greater than b, a/b = (a+b)/a.

    Implementing and Testing GOLDEN_RATIO and rnd()

    The following code fragment presents the implemention of the GOLDEN_RATIO constant and the rnd()
    method:

    Math.GOLDEN_RATIO = 1.61803398874;
    
    Math.rnd =
       function(limit)
       {
          if (typeof limit != "number")
             throw "illegal argument: " + limit;
      
          return Math.random() * limit | 0;
       };

    After defining the GOLDEN_RATIO object property, this code fragment defines the rnd() object method, which takes a limit argument. This argument must be numeric; if not, an exception is thrown.

    Math.random() returns a fractional value from 0.0 through (almost) 1.0. After being multiplied by limit, a fraction remains. This fraction is removed through truncation and truncation is performed by bitwise ORing 0 with the result.

    Bitwise OR uses a ToInt32 internal function to convert its numeric operands to 32-bit signed integers. This operation eliminates the fractional part of the number and is more performant than using Math.floor() because a method call isn’t required.

    The following test cases exercise these items:

    alert("Math.GOLDEN_RATIO: " + Math.GOLDEN_RATIO);
    
    try
    {
       alert("Math.rnd(null): " + Math.rnd(null));
    }
    catch (err)
    {
       alert("null value not supported.");
    }
    alert("Math.rnd(10): " + Math.rnd(10));

    When you run these test cases, you should observe output that’s similar to the following:

    Math.GOLDEN_RATIO: 1.61803398874
    null value not supported.
    Math.rnd(10): 7

    Implementing and Testing toDegrees(), toRadians(), and trunc()

    The following code fragment presents the implementation of the toDegrees(), toRadians(), and trunc() methods:

    Math.toDegrees = 
       function(radians)
       {
          if (typeof radians != "number")
             throw "illegal argument: " + radians;
    
          return radians * (180 / Math.PI);
       };
    
    Math.toRadians = 
       function(degrees)
       {
          if (typeof degrees != "number")
             throw "illegal argument: " + degrees;
    
          return degrees * (Math.PI / 180);
       };
    
    
    Math.trunc =
       function(n)
       {
          if (typeof n != "number")
             throw "illegal argument: " + n;
      
          return (n >= 0) ? Math.floor(n) : -Math.floor(-n);
       };

    Each method requires a numeric argument and throws an exception when this isn’t the case. The first two methods perform simple conversions to degrees or radians and the third method truncates it argument via Math‘s floor() method.

    Why introduce a trunc() method when floor() already performs truncation? When it receives a negative non-integer argument, floor() rounds this number down to the next highest negative integer. For example, floor() converts -4.1 to -5 instead of the more desirable -4.

    The following test cases exercise these items:

    try
    {
       alert("Math.toDegrees(null): " + Math.toDegrees(null));
    }
    catch (err)
    {
       alert("null degrees not supported.");
    }
    alert("Math.toDegrees(Math.PI): " + Math.toDegrees(Math.PI));
    
    try
    {
       alert("Math.toRadians(null): " + Math.toRadians(null));
    }
    catch (err)
    {
       alert("null radians not supported.");
    }
    alert("Math.toRadians(180): " + Math.toRadians(180));
    
    try
    {
       alert("Math.trunc(null): " + Math.trunc(null));
    }
    catch (err)
    {
       alert("null value not supported.");
    }
    alert("Math.trunc(10.83): " + Math.trunc(10.83));
    alert("Math.trunc(-10.83): " + Math.trunc(-10.83));

    When you run these test cases, you should observe the following output:

    null degrees not supported.
    Math.toDegrees(Math.PI): 180
    null radians not supported.
    Math.toRadians(180): 3.141592653589793
    null value not supported.
    Math.trunc(10.83): 10
    Math.trunc(-10.83): -10

    Augmenting Number

    The Number object is an object wrapper for 64-bit double precision floating-point numbers. The following code fragment presents the implementation of a trunc() instance method that’s similar to its object method counterpart in the Math object:

    Number.prototype.trunc = 
       function()
       {
          var num = this;
          return (num < 0) ? -Math.floor(-num) : Math.floor(num);
       };

    The following test cases exercise this method:

    alert("(25.6).trunc(): " + (25.6).trunc());
    alert("(-25.6).trunc(): " + (-25.6).trunc());
    alert("10..trunc(): " + 10..trunc());

    The two dots in 10..trunc() prevent the JavaScript parser from assuming that trunc is the fractional part (which would be assumed when encountering 10.trunc()) and reporting an error. To be clearer, I could place 10. in round brackets, as in (10.).trunc().

    When you run these test cases, you should observe the following output:

    (25.6).trunc(): 25
    (-25.6).trunc(): -25
    10..trunc(): 10

    Augmenting String

    The String object is an object wrapper for strings. I’ve added endsWith(), reverse(), and startsWith() methods that are similar to their Java language counterparts to this object.

    Implementing and Testing endsWith() and startsWith()

    The following code fragment presents the implemention of endsWith() and startsWith() methods that perform case-sensitive comparisons of a suffix or prefix with the end or start of a string, respectively:

    String.prototype.endsWith = 
       function(suffix) 
       {
          if (typeof suffix != "string")
             throw "illegal argument" + suffix;
    
          if (suffix == "")
             return true;
    
          var str = this;
          var index = str.length - suffix.length;
          return str.substring(index, index + suffix.length) == suffix;
       };
    
    String.prototype.startsWith = 
       function(prefix)
       {
          if (typeof prefix != "string")
             throw "illegal argument" + prefix;
    
          if (prefix == "")
             return true;
    
          var str = this;
          return str.substring(0, prefix.length) == prefix;
       };

    Each of endsWith() and startsWith() is similar in that it first verifies that its argument is a string, throwing an exception when this isn’t the case. It then returns true when its argument is the empty string because empty strings always match.

    Each method also uses String‘s substring() method to extract the appropriate suffix or prefix from the string before the comparison. However, they differ in their calculations of the start and end indexes that are passed to substring().

    The following test cases exercise these methods:

    try
    {      
       alert("'abc'.endsWith(undefined): " + "abc".endsWith(undefined));
    }
    catch (err)
    {
       alert("not a string");
    }
    alert("'abc'.endsWith(''): " + "abc".endsWith(""));
    alert("'this is a test'.endsWith('test'): " +
          "this is a test".endsWith("test"));
    alert("'abc'.endsWith('abc'): " + "abc".endsWith("abc"));
    alert("'abc'.endsWith('Abc'): " + "abc".endsWith("Abc"));
    alert("'abc'.endsWith('abcd'): " + "abc".endsWith("abcd"));
    
    try
    {      
       alert("'abc'.startsWith(undefined): " + "abc".startsWith(undefined));
    }
    catch (err)
    {
       alert("not a string");
    }
    alert("'abc'.startsWith(''): " + "abc".startsWith(""));
    alert("'this is a test'.startsWith('this'): " +
          "this is a test".startsWith("this"));
    alert("'abc'.startsWith('abc'): " + "abc".startsWith("abc"));
    alert("'abc'.startsWith('Abc'): " + "abc".startsWith("Abc"));
    alert("'abc'.startsWith('abcd'): " + "abc".startsWith("abcd"));

    When you run these test cases, you should observe the following output:

    not a string
    'abc'.endsWith(''): true
    'this is a test'.endsWith('test'): true
    'abc'.endsWith('abc'): true
    'abc'.endsWith('Abc'): false
    'abc'.endsWith('abcd'): false
    not a string
    'abc'.startsWith(''): true
    'this is a test'.startsWith('this'): true
    'abc'.startsWith('abc'): true
    'abc'.startsWith('Abc'): false
    'abc'.startsWith('abcd'): false

    Implementing and Testing reverse()

    The following code fragment presents the implemention of a reverse() method that reverses the characters of the string on which this method is called and returns the resulting string:

    String.prototype.reverse = 
       function()
       {
          var str = this;
          var revStr = "";
          for (var i = str.length - 1; i >= 0; i--)
             revStr += str.charAt(i);
          return revStr;
       };

    reverse() loops over the string backwards and appends each character to a temporary string variable, which is returned. Because string concatenation is expensive, you might prefer an array-oriented expression such as return this.split("").reverse().join("");.

    The following test case exercises this method:

    alert("'abc'.reverse(): " + "abc".reverse());

    When you run this test case, you should observe the following output:

    'abc'.reverse(): cba

    Conclusion

    JavaScript makes it easy to augment its core objects with new capabilities and you can probably think of additional examples.

    I find it easiest to place all of a core object’s new property and method definitions in a separate file (e.g., date.js) and include the file in a page’s header via a <script> element (e.g., <script type="text/javascript" src="date.js"><script>).

    For homework, add a shuffle() method to the Array object to shuffle an array of elements (e.g., playing card objects). Use this article’s rnd() method in the implementation.