Extending JavaScript Arrays While Keeping Native Bracket-Notation Functionality
In JavaScript, we can sub-class native data types by extending the native prototypes. This works perfectly with the native String object; but, when it comes to native Arrays, things don't work quite so nicely. If we extend the Array prototype, we inherit the native array functions; but, we no longer have the ability to use bracket notation to set and get indexed values within the given array. Sure, we can use push() and pop() to overcome this limitation; but, if we want to keep the bracket notation feature functional, we have to build on top of an existing array instance rather than truly sub-classing the Array object.
Typically, when building a sub-class in JavaScript, you extend the super class' prototype and then define your sub-class class methods. When your sub-class is then instantiated, your new object automatically gets all of the functionality defined in the prototype, the super-class prototype, and the rest of the prototype chain. When it comes to "sub-classing" an Array, however, we can't quite use such an elegant approach; instead of putting our sub-class methods in the prototype chain, we have to inject the sub-class methods into the object as part of the instantiation process.
To experiment with this approach, I wanted to create a sub-class of the native JavaScript array called a Collection. The Collection would have all the features of a normal array, plus some utility features. And, this would all be done without modifying the native Array prototype.
collection.js (Our Array Sub-Class)
// Define the collection class.
window.Collection = (function(){
// I am the constructor function.
function Collection(){
// When creating the collection, we are going to work off
// the core array. In order to maintain all of the native
// array features, we need to build off a native array.
var collection = Object.create( Array.prototype );
// Initialize the array. This line is more complicated than
// it needs to be, but I'm trying to keep the approach
// generic for learning purposes.
collection = (Array.apply( collection, arguments ) || collection);
// Add all the class methods to the collection.
Collection.injectClassMethods( collection );
// Return the new collection object.
return( collection );
}
// ------------------------------------------------------ //
// ------------------------------------------------------ //
// Define the static methods.
Collection.injectClassMethods = function( collection ){
// Loop over all the prototype methods and add them
// to the new collection.
for (var method in Collection.prototype){
// Make sure this is a local method.
if (Collection.prototype.hasOwnProperty( method )){
// Add the method to the collection.
collection[ method ] = Collection.prototype[ method ];
}
}
// Return the updated collection.
return( collection );
};
// I create a new collection from the given array.
Collection.fromArray = function( array ){
// Create a new collection.
var collection = Collection.apply( null, array );
// Return the new collection.
return( collection );
};
// I determine if the given object is an array.
Collection.isArray = function( value ){
// Get it's stringified version.
var stringValue = Object.prototype.toString.call( value );
// Check to see if the string represtnation denotes array.
return( stringValue.toLowerCase() === "[object array]" );
};
// ------------------------------------------------------ //
// ------------------------------------------------------ //
// Define the class methods.
Collection.prototype = {
// I add the given item to the collection. If the given item
// is an array, then each item within the array is added
// individually.
add: function( value ){
// Check to see if the item is an array.
if (Collection.isArray( value )){
// Add each item in the array.
for (var i = 0 ; i < value.length ; i++){
// Add the sub-item using default push() method.
Array.prototype.push.call( this, value[ i ] );
}
} else {
// Use the default push() method.
Array.prototype.push.call( this, value );
}
// Return this object reference for method chaining.
return( this );
},
// I add all the given items to the collection.
addAll: function(){
// Loop over all the arguments to add them to the
// collection individually.
for (var i = 0 ; i < arguments.length ; i++){
// Add the given value.
this.add( arguments[ i ] );
}
// Return this object reference for method chaining.
return( this );
}
};
// ------------------------------------------------------ //
// ------------------------------------------------------ //
// ------------------------------------------------------ //
// ------------------------------------------------------ //
// Return the collection constructor.
return( Collection );
}).call( {} );
As part of this create-and-build workflow, we have to manually instantiate a new array. And, since the JavaScript array can take constructor arguments, we have to use the apply() method to proxy the native constructor. As I talked about yesterday, using apply() to invoke a native Array constructor has some issues of its own. Once we have our new array instance, however, we simply need to append our sub-class properties (including methods) and return the synthesized object.
In this case, we are adding the following methods to our JavaScript Array sub-class:
- add( value | array )
- addAll( value1, value2, ... valueN )
Now that we have our Collection class defined, let's use it to see if we can populate and reference values using the native bracket notation.
<!DOCTYPE html>
<html>
<head>
<title>Extending JavaScript Arrays And Keeping Native Features</title>
<!-- Include the Collection class. -->
<script type="text/javascript" src="./collection.js"></script>
<script type="text/javascript">
// Create a new collection with default values.
var friends = new Collection( "Sarah" );
// Use collection-based API to populate.
friends.addAll( "Tricia", "Joanna" );
// Use native array functionality to populate.
friends[ 3 ] = "Kit";
friends[ 4 ] = "Anna";
// Use native array functionality to remove.
friends.splice( 0, 1 );
// Log the current friends collection.
console.log( friends );
console.log( "Length:", friends.length );
</script>
</head>
<body>
<!-- Left intentionally blank. -->
</body>
</html>
As you can see, we instantiate our Collection with a constructor argument. Then we use both the sub-class property methods as well as the native bracket-notation methods to populate the object. And, when we log out the resultant array sub-class, we get the following console output:
["Tricia", "Joanna", "Kit", "Anna"]
Length: 4
As you can see, not only did we keep the use of bracket notation to update the Array sub-class, the length property was maintained as well. Of course, this isn't surprising since we aren't technically using a sub-class - we're using an array instance. It's just that we've added our sub-class features to the existing array instance.
With any programming language, it is often the small features that make the language enjoyable to use. When it comes to Arrays in JavaScript, there's no doubt that being able to use bracket notation is one of those features. And, in order to sub-class a native JavaScript Array while keeping this feature in-tact, we have to use some smoke and mirrors.
Want to use code from this post? Check out the license.
Reader Comments
Array.apply looks like a nice way to clone an array (its children still only being copied by reference). myArr.concat() and myArry.slice() works as well.
I've had luck sub-classing Array by simply assigning myConstructor.prototype = []; and this.length inside myConstructor.
IE7 doesn't set the length property correctly but you can test for it with something like:
@Ben:
If you were actually trying to do something like this for real code, I wouldn't bother defining things in the Collection.prototype--I'd just immediately extend the Array object you create in the Collection() constructor.
Since you're not actually returning an instance of the Collection, adding the methods to the Collection doesn't gain you anything and the looping in the injectClassMethods() just slows things down.
I'd instead, just do:
collection = (Array.apply( collection, arguments ) || collection);
collection.add = function (){};
collection.addAll = function ();
The end result is the same, but you've removed some of the unneeded processing.
@Bob,
In my experimentation, at least on Firefox, even when you set the prototype of the sub-class to be the array literal, "[]", you still lose the ability to use bracket-notation for setting values into the array. Building on top of an array instance has been the only way I could figure out how to keep the use of something like:
It's like bracket notation is some magical part of the language :(
As far as using Array.apply() as a means to duplicate a top-level array, I totally hadn't thought of that - awesome insight!
@Dan,
Using "Collection.prototype", I was trying to accomplish two things:
1) Strictly Emotional: I wanted to give the impression of actually sub-classing an object. As such, I wanted to pretend to use the class prototype as a way to define the class methods.
2) Function References: Assuming that Collection() could create multiple instances (as any constructor function would), I wanted to simply copy function references into the new collection, rather than redefining the methods for each collection instance.
That said, if I only wanted to create a one-off Collection situation, I definitely agree that "inject" approach would do little more than add unnecessary overhead.
@Ben,
I tried the Catalog example in FF from my previous post and it seemed to work fine at first.
But then I dug a little deeper to try and get at the problem you were seeing and I realized my approach comes with a caveat that I hadn't realized before. The Catalog class can only use direct assignment with existing indexes because the length property is no longer maintained properly when adding new indexes directly and in some cases (like adding to an empty array in FF) the assignment itself fails.
I guess I've never noticed this behavior because adding to an array using push feels better to me than assigning it directly.
Thanks for pointing this out. It is good to know the the limitations of the prototype = [] solution.
@Bob,
I'll side with you that perhaps using push/pop feels a bit more natural. But, I definitely use direct index assignment a good deal. This is especially true if I need to "param" an array value... though, granted, I think I do that more on the server-side (in ColdFusion) than I do in JavaScript.
I also didn't realize that there were differences between FF and Chrome on this issue. I typically do all my R&D in Firefox since I *still* find Firebug to be a much better experience than any of the other browser's built-in debug tools and JavaScript consoles. People tell me that the Chrome tools are getting better every day. Perhaps I will start trying to do some R&D in Chrome to get used to them.
I like the idea of extending Array and Object, but I've found that doing so can have unexpected results. For example, extending Array breaks attribute handling in SVGElement in Webkit.
Awesome article. I've been looking for something like this. I have one question that I am going to explore on my own, but I thought it might be valuable for others ending up here.
If I am going to inherit the Collection class into a more-specific (Type)Collection class, do I need to: repeat these procedures a second time in the (Type)Collection constructor, move these procedures up into the (Type)Collection constructor (and remove from Collection) or do nothing but inherit Collection?
I am fairly new to javascript (at least on any real level), but I understand enough to know what's going on here. My guess is that I would need to repeat this in my (Type)Collection class.
Thanks.
Thanks a lot ;)
I'm here to figure out how to fix the .concat method. As a note, @BobGray's method works, but only in one direction. Here's what I mean:
If you use this to fix concat:
Catalog.prototype.concat = function () {
return Array.prototype.concat.apply( this.slice(), arguments );
};
Then this will work:
myCatalog.concat([1,3,7]);
But this will not:
[1.3.7].concat(myCatalog);
I believe this is because concat inspects the [[Class]] internal property of the arguments it is given in order to determine whether it's dealing with an array or not. Source: http://www.ecma-international.org/ecma-262/5.1/#sec-15.4.4 bullet 5.b.
I have not found a solution for this, but would definitely like one.
@Gordon,
Check out Backbone's extend implementation. You should be able to write both the array extension and an extend method on your base Collection class.
http://backbonejs.org/docs/backbone.html#section-206
I am trying to translate Ben's code into this use case now.
@Ben,
Have you run this test against it?
function isArray(x) { return [].concat(x)[0] !== x; }
I pulled it from the below StackOverflow question. Concat works against and internal and maybe unsettable property so I think it may be impossible to actually extend an array perfectly using any method, but would love some confirmation.
http://stackoverflow.com/questions/8672204/can-you-set-the-internal-class-property-of-an-ecmascript-object
I'll answer myself--looks like concat works property against the Nadel Array Extension method :)
function concatWorks(x) { return [].concat(x)[0] !== x; }
myCollection = new Collection();
console.log(concatWorks(myCollection)); // true
You can skip the loop by using apply in the add method:
// Check to see if the item is an array.
if (Array.isArray(value)) {
// Add each sub-item using default push() method.
Array.prototype.push.apply(this, value);
}
A small performance improvement.
How would one go about if trying to get this type to pass instanceOf tests? That checks on the prototype, and as you are returning an array that of course fails. "No can do" is also an answer, btw :)