Extending JavaScript Arrays with TypeScript

I have been working on yet another Angular app for a few months.  Recently I found myself on a complex screen.  I discovered that I have to manipulate arrays in TypeScript quite a bit.  I found myself missing some functionality that I am accustomed to in C#, such as Linq.  I also found some older JavaScript Array Apis a bit confusing, such as removing items.  So, instead of downloading some giant library, I decided to write a couple dozen lines of code to only implement what I need.  At this point the list is small, but I think it will eventually grow some.

So, in this blog I will share this small adventure.  I started with the blueprint for my changes.  Because Array in TypeScript does not have an interface, I simply add a new TypeScript file and define the same Array class.  TypeScript’s Intellisense is smart enough to combine Array<T> that comes with TypeScript that is defined in core JS file, lib.d.ts and the one I added manually.  Here is what I added in a file called tsarray.d.ts

interface Array<T> { firstOrDefault(predicate: (item: T) => boolean): T; where(predicate: (item: T) => boolean): T[]; orderBy(propertyExpression: (item: T) => any): T[]; orderByDescending(propertyExpression: (item: T) => any): T[]; orderByMany(propertyExpressions: [(item: T) => any]): T[]; orderByManyDescending(propertyExpressions: [(item: T) => any]): T[]; remove(item: T): boolean; add(item: T): void; addRange(items: T[]): void; removeRange(items: T[]): void; }

First function I found needing often was firstOrDefault.  Essentially I do not want to use angular.forEach because I cannot break out of the loop when an item is found.  Writing traditional for loop is taking just too many lines of code.  So, I am going to implement firstOrDefault to cut down on the code I have to write every day.

/// <reference path="./tsarray.d.ts" /> (function () { if (!Array.prototype.firstOrDefault) { Array.prototype.firstOrDefault = function (predicate: (item: any) => boolean) { for (var i = 0; i < (<Array<any>>this).length; i++) { let item = (<Array<any>>this)[i]; if (predicate(item)) { return item; } } return null; } }

I put implementation into tsarray.ts file. I am starting with self executing immediate function.  In it I first check to see if my new function exists on array prototype, and if not I add it.  The function is trivial.  My predicate or filter is defined as a function that takes my array item as a parameter and returns a Boolean.  I expect that calling code uses strongly typed TypeScript array, so the function signature above should be restrictive to ensure that proper item type is passed in.  Then I loop through the array items, calling a function for each one, and breaking out of the loop when one is found.  If nothing is found, I return null at the end.  Here is what my Jasmine test looks like. I called it “Should find first item”.  I create my test array inside beforeEach function.

describe("TS Array suite", () => { var items: Array<Person>; beforeEach(function () { items = [{ id: 1, name: "Joe" }, { id: 2, name: "Jane" }, { id: 3, name: "Thomas" }]; }); it("Should create array with no items", () => { expect(items).toBeDefined(); expect(items.length).toBe(3); }); it("Should find first item", () => { var first = items.firstOrDefault((item) => { return item.name === "Joe"; }); expect(first.id).toBe(1); });

 

Of course I get full Intellisense support as I call firstOrDefault, such as list of properties inside Person class.

class Person { id: number; name: string; }

Here is the remaining methods’ implementation.

/// <reference path="./tsarray.d.ts" /> (function () { if (!Array.prototype.firstOrDefault) { Array.prototype.firstOrDefault = function (predicate: (item: any) => boolean) { for (var i = 0; i < (<Array<any>>this).length; i++) { let item = (<Array<any>>this)[i]; if (predicate(item)) { return item; } } return null; } } if (!Array.prototype.where) { Array.prototype.where = function (predicate: (item: any) => boolean) { let result = []; for (var i = 0; i < (<Array<any>>this).length; i++) { let item = (<Array<any>>this)[i]; if (predicate(item)) { result.push(item); } } return result; } } if (!Array.prototype.remove) { Array.prototype.remove = function (item: any): boolean { let index = (<Array<any>>this).indexOf(item); if (index >= 0) { (<Array<any>>this).splice(index, 1); return true; } return false; } } if (!Array.prototype.removeRange) { Array.prototype.removeRange = function (items: any[]): void { for (var i = 0; i < items.length; i++) { (<Array<any>>this).remove(items[i]); } } } if (!Array.prototype.add) { Array.prototype.add = function (item: any): void { (<Array<any>>this).push(item); } } if (!Array.prototype.addRange) { Array.prototype.addRange = function (items: any[]): void { for (var i = 0; i < items.length; i++) { (<Array<any>>this).push(items[i]); } } } if (!Array.prototype.orderBy) { Array.prototype.orderBy = function (propertyExpression: (item: any) => any) { let result = []; var compareFunction = (item1: any, item2: any): number => { if (propertyExpression(item1) > propertyExpression(item2)) return 1; if (propertyExpression(item2) > propertyExpression(item1)) return -1; return 0; } for (var i = 0; i < (<Array<any>>this).length; i++) { return (<Array<any>>this).sort(compareFunction); } return result; } } if (!Array.prototype.orderByDescending) { Array.prototype.orderByDescending = function (propertyExpression: (item: any) => any) { let result = []; var compareFunction = (item1: any, item2: any): number => { if (propertyExpression(item1) > propertyExpression(item2)) return -1; if (propertyExpression(item2) > propertyExpression(item1)) return 1; return 0; } for (var i = 0; i < (<Array<any>>this).length; i++) { return (<Array<any>>this).sort(compareFunction); } return result; } } if (!Array.prototype.orderByMany) { Array.prototype.orderByMany = function (propertyExpressions: [(item: any) => any]) { let result = []; var compareFunction = (item1: any, item2: any): number => { for (var i = 0; i < propertyExpressions.length; i++) { let propertyExpression = propertyExpressions[i]; if (propertyExpression(item1) > propertyExpression(item2)) return 1; if (propertyExpression(item2) > propertyExpression(item1)) return -1; } return 0; } for (var i = 0; i < (<Array<any>>this).length; i++) { return (<Array<any>>this).sort(compareFunction); } return result; } } if (!Array.prototype.orderByManyDescending) { Array.prototype.orderByManyDescending = function (propertyExpressions: [(item: any) => any]) { let result = []; var compareFunction = (item1: any, item2: any): number => { for (var i = 0; i < propertyExpressions.length; i++) { let propertyExpression = propertyExpressions[i]; if (propertyExpression(item1) > propertyExpression(item2)) return -1; if (propertyExpression(item2) > propertyExpression(item1)) return 1; } return 0; } for (var i = 0; i < (<Array<any>>this).length; i++) { return (<Array<any>>this).sort(compareFunction); } return result; } } })();

Some methods are there to match List<T> API in.NET. This just makes it easier for the new developers to adjust to TypeScript coming from C#.  Specifically, add, remove, add range and remove range methods are the ones I am talking about. 

The remainder of the methods are just some basic Linq functions.  There is certainly more room for improvement, as the API for Linq is much larger than what you see here.  However, for now this is all I need.  The entire implementation is about 100 lines of code, and pure Linq functions are about 1/2 of that.  So, with 2 hours of work I can write a lot less code for a long time, and I do not have to download a large library that does 10 more work that I actually need.  Sort of vanilla JS inside TypeScript.  Maybe I should call it Chocolate TS.

 

Here is my test suite if you are interested.

/// <reference path="../typings/index.d.ts" /> class Person { id: number; name: string; } describe("TS Array suite", () => { var items: Array<Person>; beforeEach(function () { items = [{ id: 1, name: "Joe" }, { id: 2, name: "Jane" }, { id: 3, name: "Thomas" }]; }); it("Should create array with no items", () => { expect(items).toBeDefined(); expect(items.length).toBe(3); }); it("Should find first item", () => { var first = items.firstOrDefault((item) => { return item.name === "Joe"; }); expect(first.id).toBe(1); }); it("Should not find invalid item", () => { var first = items.firstOrDefault((item) => { return item.name === "z"; }); expect(first).toBeNull(); }); it("Should filter items", () => { var result = items.where((item) => { return item.id >= 2; }); expect(result.length).toBe(2); }); it("Should remove an item", () => { var result = items.remove(items.firstOrDefault((item) => { return item.id >= 2; })); expect(result).toBe(true); expect(items.length).toBe(2); }) it("Should not remove an item", () => { var result = items.remove({ id: 4, name: "Anybody" }); expect(result).toBe(false); expect(items.length).toBe(3); }) it("Should add item", () => { items.add({ id: 4, name: "Anybody" }); expect(items.length).toBe(4); }) it("Should add item range", () => { items.addRange([{ id: 4, name: "Anybody" }, { id: 5, name: "Somebody" }]); expect(items.length).toBe(5); }) it("Should remove item range", () => { items.removeRange([items[0], items[1]]); expect(items.length).toBe(1); }) it("Should sort", () => { var testData = [{ id: 0, name: "Zane" }, { id: 1, name: "Joe" }, { id: 2, name: "Jane" }, { id: 3, name: "Thomas" }]; var sorted = testData.orderBy((item) => item.name); expect(sorted[0].name).toBe("Jane"); expect(sorted[1].name).toBe("Joe"); expect(sorted[2].name).toBe("Thomas"); expect(sorted[3].name).toBe("Zane"); }) it("Should sort descending", () => { var testData = [{ id: 0, name: "Zane" }, { id: 1, name: "Joe" }, { id: 2, name: "Jane" }, { id: 3, name: "Thomas" }]; var sorted = testData.orderByDescending((item) => item.name); expect(sorted[3].name).toBe("Jane"); expect(sorted[2].name).toBe("Joe"); expect(sorted[1].name).toBe("Thomas"); expect(sorted[0].name).toBe("Zane"); }) it("Should sort by many", () => { var testData = [{ id: 0, name: "Zane" }, { id: 1, name: "Joe" }, { id: 2, name: "Thomas" }, { id: 3, name: "Jane" }, { id: 4, name: "Thomas" }]; var sorted = testData.orderByMany([(item) => item.name, (item) => item.id]); expect(sorted[0].name).toBe("Jane"); expect(sorted[1].name).toBe("Joe"); expect(sorted[2].name).toBe("Thomas"); expect(sorted[2].id).toBe(2); expect(sorted[3].name).toBe("Thomas"); expect(sorted[3].id).toBe(4); expect(sorted[4].name).toBe("Zane"); }) it("Should sort by many descending", () => { var testData = [{ id: 0, name: "Zane" }, { id: 1, name: "Joe" }, { id: 2, name: "Thomas" }, { id: 3, name: "Jane" }, { id: 4, name: "Thomas" }]; var sorted = testData.orderByManyDescending([(item) => item.name, (item) => item.id]); expect(sorted[4].name).toBe("Jane"); expect(sorted[3].name).toBe("Joe"); expect(sorted[2].name).toBe("Thomas"); expect(sorted[2].id).toBe(2); expect(sorted[1].name).toBe("Thomas"); expect(sorted[1].id).toBe(4); expect(sorted[0].name).toBe("Zane"); }) })

Enjoy.

5 Comments

  1. Hi Sergey,

    I was looking exactly for the same functionality, thanks for the post. The problem is I’am getting the error below:

    ERROR TypeError: Object doesn’t support property or method ‘add’

    I am using Visual Studio 2017 targeting es5 and commonjs. Any help on this topic would be great.

  2. Good article – I’ve done the same, however I’ve never managed to get intellisense. For some reason typescript converts the T to {} inside the call. So I get these messages [ts] Property ‘foo’ does not exist on type ‘{}’

    Any idea how to fix that?

Leave a Reply

Your email address will not be published. Required fields are marked *