Declarative Sorting Functions in JavaScript

Sorting arrays in JavaScript can be more complex than what it sounds. The default sort() behavior for example coerces the elements of the array to strings, making it unsuitable to sort arrays of numbers:

[1, 4, 10, 3, 7].sort();
// results in: [1, 10, 3, 4, 7] 🤔

To correctly sort the above array, we need to pass a function as first argument to the sort() method:

[1, 4, 10, 3, 7].sort(function(a, b) {
  if (a < b) {
	return -1;
  }
  if (a > b) {
    return 1;
  }
  return 0;
});
// results in: [1, 3, 4, 7, 10]

In this case, it is possible to simplify the custom function as follows:

[1, 4, 10, 3, 7].sort((a, b) => a - b);
// results in: [1, 3, 4, 7, 10]

But the main issue persist: as a programmer, you need to tell the sort function what to do in all but the most trivial of cases.

Wouldn't it be more idiomatic to build a set of declarative sorting functions, so that we can write code like the following?

[1, 4, 10, 3, 7].sort(numerically);
// results in: [1, 3, 4, 7, 10]

If we look at slightly more complex cases, such as an array of objects, we would ultimately like to build sorting functions that would allow to write the following code:

const people = [
  { name: "Alice", birthday: "20/03/1998" },
  { name: "Bob", birthday: "14/10/1985" },
  { name: "Candice", birthday: "20/03/1998" },
  { name: "Tom", birthday: "22/03/2002" },
];

people.sort(combine([chronologically.by("birthday").desc, alphabetically.by("name")]));

// Sorts the array, first chronologically, and for cases where the birthday is the same, sorts by name:
/*
results in: [
  { name: "Tom", birthday: "22/03/2002" },
  { name: "Alice", birthday: "20/03/1998" },
  { name: "Candice", birthday: "20/03/1998" },
  { name: "Bob", birthday: "14/10/1985" },
]
*/

Alright, so let's build it!

Basic Sorting Functions

First, let's look at some basic sorting functions that interact with arrays of primitive values. We want to write functions that would handle the following sorts:

  • alphabetically
  • numerically
  • chronologically

The most straightforward function is the one needed to sort alphabetically:

function alphabetically(a, b) {
  const aVal = String(a);
  const bVal = String(b);

  if (aVal < bVal) {
	return -1;
  }
  if (aVal > bVal) {
    return 1;
  }
  return 0;
}

["Tom", "Bob", "Alice", "Candice"].sort(alphabetically);
// results in: ["Alice", "Bob", "Candice", "Tom"]

Sorting numerically is actually also quite similar:

function numerically(a, b) {
  const aVal = Number(a);
  const bVal = Number(b);

  if (aVal < bVal) {
	return -1;
  }
  if (aVal > bVal) {
    return 1;
  }
  return 0;
}

[1, 4, 10, 3, 7].sort(numerically);
// results in: [1, 3, 4, 7, 10]

Unsurprisingly, sorting chronologically can be achieved by:

function chronologically(a, b) {
  const aVal = new Date(a).getTime();
  const bVal = new Date(b).getTime();

  if (aVal < bVal) {
	return -1;
  }
  if (aVal > bVal) {
    return 1;
  }
  return 0;
}

["20/03/1998", "14/10/1985", "22/03/2002"].sort(chronologically);
// results in: ["14/10/1985", "20/03/1998", "22/03/2002"]

If you look closely at those functions, there is quite a lot of repetition in their implementations. It makes sense to refactor them and use a higher-order function to generate those sorting functions:

function createCompareFunction(coerceType) {
  return function compare (a, b) {
	const aVal = coerceType(a);
	const bVal = coerceType(b);

	if (aVal < bVal) {
      return -1;
	}
	if (aVal > bVal) {
	  return 1;
	}
	return 0;
  }
}

const alphabetically = createCompareFunction((val) => String(val));
const numerically = createCompareFunction((val) => Number(val));
const chronologically = createCompareFunction((val) => new Date(val).getTime());

The above abstraction will be quite handy when we start building more complex features in our compare functions!

Sorting Direction

We can know sort arrays of primitive values alphabetically, numerically, or chronologically, but only in ascending order. It would be nice to be able to sort them in descending order as well.

There are many ways to implement sorting direction into our utility functions, but the one that looks more declarative and fluent is to do the following:

[1, 4, 10, 3, 7].sort(numerically.desc);
// results in: [10, 7, 4, 3, 1]

Of course, for completeness, we will add an .sort(numerically.asc) as well, and to keep backwards compatibility with our previous implementation, .sort(numerically) will use the ascending order by default.

To implement such functions, we need to update our createCompareFunction as follows:

function createCompareFunction(coerceType) {
  function compare (a, b, direction = 1) {
	const aVal = coerceType(a);
	const bVal = coerceType(b);

	if (aVal < bVal) {
      return -1 * direction;
	}
	if (aVal > bVal) {
	  return 1 * direction;
	}
	return 0;
  }
	
  compare.asc = (a, b) => compare(a, b, 1);
  compare.desc = (a, b) => compare(a, b, -1);
	
  return compare;
}

There are a few interesting tricks here.

The first one is that a function is just an Object in JavaScript. That means that we can add properties to a function the same way we'd do with an object. In this case, we've added the asc and desc properties to our existing compare function.

To sort in descending order, the only thing we need to do is invert the values returned from our equality branches. We can easily do that by passing a third argument to our compare function that will be either 1 or -1.

Finally, to make sure that our default behavior is still ascending when using the compare function itself, we default the value of its direction argument to 1.

Object Key Lookup

Sorting arrays of primitive values is great, but in most real-life applications data is a bit more complicated, and often involves objects. Ideally, we can tweak the sorting functions we've defined previously to also work on arrays of objects:

const people = [
  { name: "Bob", age: 23 },
  { name: "Alice", age: 32 },
  { name: "Tom", age: 60 },
  { name: "Candice", age: 45 },
];

people.sort(numerically.by("age"));

/* results in:
[
  { name: "Bob", age: 23 },
  { name: "Alice", age: 32 },
  { name: "Candice", age: 45 },
  { name: "Tom", age: 60 },
]
*/

Leveraging our existing implementation, we can add support for field sorting as follows:

function createCompareFunction(coerceType) {
  function compare (a, b, direction = 1) {
	const aVal = coerceType(a);
	const bVal = coerceType(b);

	if (aVal < bVal) {
      return -1 * direction;
	}
	if (aVal > bVal) {
	  return 1 * direction;
	}
	return 0;
  }
	
  compare.asc = (a, b) => compare(a, b, 1);
  compare.desc = (a, b) => compare(a, b, -1);

  compare.by = (key) => {
	const aVal = coerceType(a[key]);
	const bVal = coerceType(b[key]);

	if (aVal < bVal) {
      return -1;
	}
	if (aVal > bVal) {
	  return 1;
	}
	return 0;
  }
	
  return compare;
}

We add another method called .by() that takes a key, and we lookup the key in the objects, before comparing the values as we did previously. Again, this might be a good time to add some refactoring here, as we are duplicating the comparison logic.

We can also make our createCompareFunction more flexible by passing the compare function as an argument with a sensible default.

As such, we end up with the following code:

function compareFn(a, b, direction = 1) {
  if (aVal < bVal) {
    return -1 * direction;
  }
  if (aVal > bVal) {
	return 1 * direction;
  }
  return 0;
}

function createCompareFunction(coerceType, compareValues = compareFn) {
  function compare (a, b, direction) {
	const aVal = coerceType(a);
	const bVal = coerceType(b);

	return compareValues(a, b, direction);
  }

  compare.asc = (a, b) => compare(a, b, 1);
  compare.desc = (a, b) => compare(a, b, -1);

  compare.by = (key) => {
	const aVal = coerceType(a[key]);
	const bVal = coerceType(b[key]);

	return compareValues(a, b);
  }
	
  return compare;
}

This works great ... unless we also have nested valued in our objects we want to sort on:

const people = [
  { name: "Bob", nested: { age: 23 } },
  { name: "Alice", nested: { age: 32 } },
  { name: "Tom", nested: { age: 60 } },
  { name: "Candice", nested: { age: 45 } },
];

people.sort(numerically.by("nested.age"));

/* results in:
[
  { name: "Bob", nested: { age: 23 } },
  { name: "Alice", nested: { age: 32 } },
  { name: "Candice", nested: { age: 45 } },
  { name: "Tom", nested: { age: 60 } },
]
*/

To support that usage, we need to recursively lookup object fields to retrieve the nested values. To do so, we can use a utility function that would do the heavy-lifting for us:

function getValueByKey(obj, key) {
  return String(key)
    .split(".")
    .reduce((acc, cur) => acc?.[cur] ?? null, obj);
}

We can then use that function in our createCompareFunction as follows:

function createCompareFunction(coerceType) {
  ...
  compare.by = (key) => {
	const aVal = coerceType(getValueByKey(a, key));
	const bVal = coerceType(getValueByKey(b, key));

	return compareValues(a, b);
  }
  ...
}

Finally, we can add support for .asc and .desc in the object comparison function in a similar fashion to the main compare function:

function createCompareFunction(coerceType) {
  ...
  compare.by = (key) => {
	function compareBy (a, b, direction = 1) {
	  const aVal = coerceType(getValueByKey(a, key));
	  const bVal = coerceType(getValueByKey(b, key));

	  return compareValues(a, b);
	}
 
	compareBy.asc = (a, b) => compareBy(a, b, 1);
    compareBy.desc = (a, b) => compareBy(a, b, -1);

	return compareBy;
  }
  ...
}

Combining Multiple Sorting Functions

We now have a fairly declarative way of creating idiomatic sorting functions to sort all kinds of arrays (including those with deeply nested objects). We can probably wrap things up here and call it a day, but we can also dig a bit deeper and look at combining multiple sorting functions!

If we look back at what we wanted to achieve, our final sorting utilities should allow us to write code like the following:

const people = [
  { name: "Alice", birthday: "20/03/1998" },
  { name: "Bob", birthday: "14/10/1985" },
  { name: "Candice", birthday: "20/03/1998" },
  { name: "Tom", birthday: "22/03/2002" },
];

people.sort(combine([
  chronologically.by("birthday").desc, 
  alphabetically.by("name")
]));

// Sorts the array, first chronologically, and for cases where the birthday is the same, sorts by name:
/*
results in: [
  { name: "Tom", birthday: "22/03/2002" },
  { name: "Alice", birthday: "20/03/1998" },
  { name: "Candice", birthday: "20/03/1998" },
  { name: "Bob", birthday: "14/10/1985" },
]
*/

We have all the pieces in place, except that combine() function. So let's dive straight into it! If we look at the logic we want to implement, we want to sort by the first compare function, then if there are equal values, we want to sort by the second compare function, ... recursively, until we are done sorting by all the compare functions provided (or there are no more equal values).

That looks like a recursive algorithm, and that is how we are going to implement such function:

function combine(sortFns) {
  return function (a, b) {
    if (sortFns.length === 1) {
      return sortFns[0](a, b);
    }

    const result = sortFns[0](a, b);
    if (result === 0) {
      return combine(sortFns.slice(1))(a, b);
    }
    return result;
  };
}

Creating Custom Sorting Functions

All the work we've done defining the createCompareFunction() now allows us to also create custom sorting functions in our code base.

We have built an alphabetical sorting function, but it is case sensitive. What if we want that function to be case insensitive? We can just write the following code:

const alphabeticallyBase = createCompareFunction((val) => String(val).toLowerCase());

What if we want to do locale-aware comparisons? We can achieve that with:

const alphabeticallyLocale = createCompareFunction(String, (a, b) => a.localeCompare(b));

The benefit of this approach is that we get all of the tools of the default sorting functions by default. We have access to .desc and .asc, and we can sort by any nested object property with .by()!


With that, we can complete our library of declarative sorting functions!

This is the exact implementation of a the @antoniovdlc/sort library published on npm. The source code is open source and can be found here: https://github.com/AntonioVdlC/sort.

Besides the utility of the library itself, I hope you have found the walk-through insightful, and that you maybe learned something new!

Antonio Villagra De La Cruz

Antonio Villagra De La Cruz

Multicultural software engineer passionate about building products that empower people.