While researching this, I came across Ben Nadel's excellent post on Extending JavaScript Arrays While Keeping Native Bracket-Notation Functionality. After some initial confusion on how to succesfully convert this into TypeScript, I created a fully working Collection class that can be subclassed.
It can do everything an Array can, including indexing by brackets,use in loop constructions (for, while, forEach), maps, etc.
The main implementation points are
- Create an array in the constructor, add the methods to the array and return that from the constructor
- Copy dummy declarations of Array methods to pass the
implements Array
bit
Example of usage:
var foo = new Foo({id : 1})
var c = new Collection();
c.add(foo)
c.length === 1; // => true
foo === c[0]; // => true
foo === c.find(1); // => true
I made it available as a gist, complete with tests and an example implementation of a subclass, but I present the full source here:
/*
* Utility "class" extending Array with lookup functions
*
* Typescript conversion of Ben Nadel's Collection class.
* https://gist.github.com/fatso83/3773d4cb5f39128b3732
*
* @author Carl-Erik Kopseng
* @author Ben Nadel (javascript original)
*/
export interface Identifiable {
getId : () => any;
}
export class Collection<T extends Identifiable> implements Array<T> {
constructor(...initialItems:any[]) {
var collection = Object.create(Array.prototype);
Collection.init(collection, initialItems, Collection.prototype);
return collection;
}
static init(collection, initialItems:any[], prototype) {
Object.getOwnPropertyNames(prototype)
.forEach((prop) => {
if (prop === 'constructor') return;
Object.defineProperty(collection, prop, { value: prototype[prop] })
});
// If we don't redefine the property, the length property is suddenly enumerable!
// Failing to do this, this would fail: Object.keys([]) === Object.keys(new Collection() )
Object.defineProperty(collection, 'length', {
value: collection.length,
writable: true,
enumerable: false
});
var itemsToPush = initialItems;
if (Array.isArray(initialItems[0]) && initialItems.length === 1) {
itemsToPush = initialItems[0];
}
Array.prototype.push.apply(collection, itemsToPush);
return collection;
}
// Find an element by checking each element's getId() method
public find(id:any):T;
// Find an element using a lookup function that
// returns true when given the right element
public find(lookupFn:(e:T) => boolean):T ;
find(x:any) {
var res, comparitor;
if (typeof x === 'function') {
comparitor = x;
} else {
comparitor = (e) => {
return e.getId() === x;
}
}
res = [].filter.call(this, comparitor);
if (res.length) return res[0];
else return null;
}
// Add an element
add(value:T);
// Adds all ements in the array (flattens it)
add(arr:T[]);
add(arr:Collection<T>);
add(value) {
// Check to see if the item is an array or a subtype thereof
if (value instanceof Array) {
// Add each sub-item using default push() method.
Array.prototype.push.apply(this, value);
} else {
// Use the default push() method.
Array.prototype.push.call(this, value);
}
// Return this object reference for method chaining.
return this;
}
remove(elem:T):boolean;
remove(lookupFn:(e:T) => boolean):boolean ;
remove(x:any):boolean {
return !!this._remove(x);
}
/**
* @return the removed element if found, else null
*/
_remove(x:any):T {
var arr = this;
var index = -1;
if (typeof x === 'function') {
for (var i = 0, len = arr.length; i < len; i++) {
if (x(this[i])) {
index = i;
break;
}
}
} else {
index = arr.indexOf(x);
}
if (index === -1) {
return null;
}
else {
var res = arr.splice(index, 1);
return res.length ? res[0] : null;
}
}
// dummy declarations
// "massaged" the Array interface definitions in lib.d.ts to fit here
toString:()=> string;
toLocaleString:()=> string;
concat:<U extends T[]>(...items:U[])=> T[];
join:(separator?:string)=> string;
pop:()=> T;
push:(...items:T[])=> number;
reverse:()=> T[];
shift:()=> T;
slice:(start?:number, end?:number)=> T[];
sort:(compareFn?:(a:T, b:T) => number)=> T[];
splice:(start?:number, deleteCount?:number, ...items:T[])=> T[];
unshift:(...items:T[])=> number;
indexOf:(searchElement:T, fromIndex?:number)=> number;
lastIndexOf:(searchElement:T, fromIndex?:number)=> number;
every:(callbackfn:(value:T, index:number, array:T[]) => boolean, thisArg?:any)=> boolean;
some:(callbackfn:(value:T, index:number, array:T[]) => boolean, thisArg?:any)=> boolean;
forEach:(callbackfn:(value:T, index:number, array:T[]) => void, thisArg?:any)=> void;
map:<U>(callbackfn:(value:T, index:number, array:T[]) => U, thisArg?:any)=> U[];
filter:(callbackfn:(value:T, index:number, array:T[]) => boolean, thisArg?:any)=> T[];
reduce:<U>(callbackfn:(previousValue:U, currentValue:T, currentIndex:number, array:T[]) => U, initialValue:U)=> U;
reduceRight:<U>(callbackfn:(previousValue:U, currentValue:T, currentIndex:number, array:T[]) => U, initialValue:U)=> U;
length:number;
[n: number]: T;
}
Of course, the bits on Identifiable
, the find
and remove
methods are not needed, but I supply them none the less as a full fledged example is a tad more usable than a bare-bones Collection without any methods of its own.