Sorting Complex Arrays in JavaScript

Abstract image used to represent Sorting Complex Arrays in JavaScript
Image by Patrick Hendry.

Sorting arrays and manipulating data in JavaScript is a common, often complex and difficult, part of the work we do as frontend and fullstack developers. When working with complex data structures in particular, it requires a deeper understanding of how the sort() method behaves. Sorting arrays with objects, nested properties, or multiple criteria can be tricky, but JavaScript provides powerful tools to handle these scenarios effectively, as long as we understand how to properly use them...

Here, I will explore different techniques for sorting complex arrays in JavaScript, covering common use cases and best practices. By the end, you should have a strong grasp of how to manipulate and organise data efficiently.


Understanding the sort() Method

JavaScript's builtin Array.prototype.sort() method sorts elements in place and, by default, converts those elements to strings before comparing them. However, this behaviour is not nearly as useful if you are working with numbers or objects.

Basic Sorting with Numbers

Sorting a simple array of numbers might seem straightforward, however, JavaScript's sort() method does not handle numbers as you might expect by default. Let's start with an example:

const numbers = [10, 5, 8, 1, 7];
numbers.sort();
console.log(numbers);  // Output: [1, 10, 5, 7, 8] (incorrect sorting)

What I'm demonstrating here is the classic issue that sort() presents by default, 10 comes before 5 because sort() converts the elements passed to it to strings before comparing them. In fact, if we added another value like 101 to the array, even that would be placed before 5 when using sort() in it's default configuration.

In order to use sort() to correct sort numbers, we need to use it as a higherorder function and pass a comparison function into it. Like this:

numbers.sort((a, b) => a - b);
console.log(numbers);  // Output: [1, 5, 7, 8, 10] (correct sorting)

The comparison function ensures numeric sorting by subtracting b from a. A negative return value means a should come before b, a positive value means b should come before a, and 0 treats the values as equal for this comparison. Since ECMAScript 2019, Array.prototype.sort is specified to be stable, so elements that compare as equal retain their original relative order.


Sorting an Array of Objects

When dealing with an array of objects, it becomes a little more complicated; we must specify which property to sort by in our comparison:

const people = [
  { name: "Maddie", age: 25 },
  { name: "Bob", age: 30 },
  { name: "Charlie", age: 20 }
];

people.sort((a, b) => a.age - b.age);
console.log(people);
// Output: [{ name: "Charlie", age: 20 }, { name: "Maddie", age: 25 }, { name: "Bob", age: 30 }]

In this way, the array is sorted in ascending order based on the age property.


Sorting Nested Properties

When we're dealing with deeply nested properties, we handle them in much the same way, but just by making sure that reference the correct nested property within the comparison function.

For example, if we have a nested dataset of devices and want to sort them by their price, it would look like this:

const products = [
  { name: "Laptop", details: { price: 1000 } },
  { name: "Phone", details: { price: 700 } },
  { name: "Tablet", details: { price: 500 } }
];

products.sort((a, b) => a.details.price - b.details.price);

Really, this is much the same as the example above, we've just moved one level further down the nested data tree.


Sorting by Multiple Criteria

In some cases, sorting by a single property just simply isn't enough. For example, we may need to sort by age, but if two people have the same age, we also want to sort them alphabetically by name. I came across a similar requirement in sorting search results for Virgin Atlantic, where two flights leaving at the same time should also be sorted by the airline name.

Sticking with the peopleandage example for consistently, here's how we would handle this multicriteria sorting:

people.sort((a, b) => {
  if (a.age === b.age) {
    return a.name.localeCompare(b.name);  // Sort alphabetically
  }
  return a.age - b.age;  // Sort by age first
});

This comparator orders records by age first, then uses the name as a secondary key when the ages match. That secondary comparison is distinct from the stablesort guarantee.


Case‑Insensitive String Sorting

Sorting by Unicode Value

By default, JavaScript compares strings as sequences of UTF16 code units. For the English letters in this example, uppercase letters have lower values than their lowercase counterparts, so this is casesensitive ordering.

For example, sorting ["cat", "Cat", "dog", "Dog"] gives ["Cat", "Dog", "cat", "dog"]:

const words = ["cat", "Cat", "dog", "Dog"];
words.sort();
console.log(words);  // Output: ["Cat", "Dog", "cat", "dog"]

The first letters in this example each occupy one UTF16 code unit, with these values:

  • C

    = U+0043
  • D

    = U+0044
  • c

    = U+0063
  • d

    = U+0064

In my experience, this trips up developers all the time, although hopefully it makes sense once you see it in action as above.

Sorting Without Case Sensitivity

For humanlanguage text, codeunit order is often not the intended collation. localeCompare() can compare according to a locale, and its sensitivity option can control whether case or accents distinguish values. This example also uses localeCompare() with toLowerCase(), although lowercasing is not a substitute for choosing the required locale and collation options:

const words = ["banana", "Apple", "cherry"];
words.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
console.log(words);  // Output: ["Apple", "banana", "cherry"]

Lowercasing can be enough for a simple set of English labels, but it does not define collation for every language. With localeCompare(), choose the intended locale and options. For example, a.localeCompare(b, "en", { sensitivity: "accent" }) ignores case whilst still distinguishing accents in English; sensitivity: "base" also ignores accent differences where that locale treats the letters as the same base letter.

To offer an example, in the Swedish alphabet ä comes after z, whereas in English, it could be treated as a variation of a and therefore fall immediately after it in sorting.

So, if we use localeCompare set to sv too, we get this:

const words = ["äpple", "apple", "banana"];
words.sort((a, b) => a.localeCompare(b, "sv"));
console.log(words);   // Output: ["apple", "banana", "äpple"]

For the Swedish ordering required by this example, using localeCompare() with the sv locale produces a different order from a default codeunit comparison: ["apple", "banana", "äpple"].


Wrapping Up

The comparison function is where we make the ordering rule explicit: numeric value, a property, or several properties in priority order. Without one, sort() compares string values using UTF16 code units.

For names and other humanlanguage text, decide which locale, case and accent rules the product needs. localeCompare() can express those choices; its default settings are not a promise of caseinsensitive sorting.

Key Takeaways

  • Without a comparator, sort() compares string values by UTF16 code units.
  • Sorting numbers requires explicit subtraction (a - b) to ensure correct numerical ordering.
  • Object sorting relies on accessing the correct property and handling nested structures where necessary.
  • Multicriteria sorting enables prioritisation when sorting by multiple fields.
  • Use localeCompare() with the intended locale and sensitivity options when language, case or accent rules matter.
  • Understanding default sorting behaviour helps prevent unexpected results and ensures predictable data handling.

Planning a platform change?

I help teams make difficult platform work clearer, from architecture decisions and migrations to launch recovery, performance, and search visibility.