How to sort arrays reliably in TypeScript

How to sort arrays reliably in TypeScript

Why you should never rely on the default sort

Let's say you have an array of numbers and need to sort them; it's easy to just call .sort() and be on your way, like this:

const numbers = [10, 25, 2, 4, 1, 100, 6]

numbers.sort()

You would probably assume that since the values are numbers, it would sort them from smallest to biggest, right? Unfortunately, that's not the case and you end up with:

console.log(numbers)

// expected: [1, 2, 4, 6, 10, 25, 100]
// actual: [1, 10, 100, 2, 25, 4, 6]

The sort method's default comparison function converts values to strings before comparing them. So it converts the numbers to strings and then compares them alphabetically.

Since the default sort comparison converts values to strings and then compares them, you'd assume that it's safe to rely on it when sorting an array of strings, right? Unfortunately, that's not the case here either:

const letters = ['a', 'A', 'b', 'B']

letters.sort()
// expected: ['a', 'A', 'b', 'B']
// actual: ['A', 'B', 'a', 'b']

The default compare function sorts strings based on their UTF-16 values. This means that all uppercase characters are sorted before lowercase characters, and characters with accents are also sorted after characters without accents.

Customizing how an array is sorted

Since the default way arrays are sorted probably isn't what you want in practice, let's see how we can customize it so that it works the way you'd expect.

The .sort() method accepts a single parameter, which is called a compare function. This compare function should expect to be passed two values from the array and should return a number that indicates how those two items should be sorted. The .sort() method then calls this compare function to compare each item in the array to properly sort it.

If you were to define a type for the compare function, it would look like this:

interface SortCompareFunction<ArrayItemType> {
  (firstItem: ArrayItemType, secondItem: ArrayItemType): number
}

The way that the items are sorted depends on the number that the compare function returns and is based on if it's 0, positive, or negative.

Return ValueSort Order
Number greater than zero (> 0)First item should appear first
Number less than zero (< 0)Second item should appear first
Number equals zero (=== 0)Maintain original sort order

How to sort numbers

Since the compare function is expected to return a number, you can just subtract one number from the other and return the difference. If the values are the same then it'll return 0, if the first is bigger, it will return a positive number, otherwise, it will return a negative number.

function compareNumbersInAscendingOrder(firstNumber: number, secondNumber: number) {
  return firstNumber - secondNumber
}

function compareNumbersInDescendingOrder(firstNumber: number, secondNumber: number) {
  return secondNumber - firstNumber
}

numbers.sort(compareNumbersInAscendingOrder)
// output: [1, 2, 4, 6, 10, 25, 100]

numbers.sort(compareNumbersInDescendingOrder)
// output: [100, 25, 10, 6, 4, 2, 1]

How to sort strings

Strings come with their own localeCompare method which can be used to sort strings how you might expect. It's based on the Int.Collator constructor and accepts all the same options so that you can further customize how strings are compared.

function compareStringsInAscendingOrder(firstString: string, secondString: string) {
  // Set the locale based on the string's locale, otherwise set to undefined to use the browser's locale.
  return firstString.localeCompare(secondString, 'en', { caseFirst: 'upper' })
}

function compareStringsInDescendingOrder(firstString: string, secondString: string) {
  return secondString.localeCompare(firstString, 'en', { caseFirst: 'upper' })
}

letters.sort(compareStringsInAscendingOrder)
// output: ["A", "a", "B", "b"] 

letters.sort(compareStringsInDescendingOrder)
// output: ["b", "B", "a", "A"]

Depending on how often you're sorting and how big the array is that you're sorting, you may run into some performance issues since each time the localeCompare is called a new instance of the Intl.Collator is created. You can resolve this by creating your own instance of the Intl.Collator once and then just referencing it.

const enCollator = new Intl.Collator('en', { caseFirst: 'upper' })

hugeListOfWordsThatNeedSorting.sort(enCollator.compare)

Reusing compare functions

While the examples above declare the compare functions on their own, it's more common to see the compare function declared directly inside the .sort() method as an anonymous function like so:

numbers.sort((firstNumber, secondNumber) => {
  return firstNumber - secondNumber
})

This can be good for one-off and shorter compare functions, but if you're doing a lot of sorting it can be beneficial to share compare functions from a common location so they can be re-used throughout your app since there's a good chance that many of them can be highly reusable.

import { byNumberAsc } from '~/utils/sort'

numbers.sort(byNumberAsc)

Eslint rule

If you use ESLint with typescript-eslint, there's actually a rule called require-array-sort-compare you can enable to help make sure you never forget to provide a compare function when sorting. It can be easy to forget and have your app work locally when testing with trivial data only to find out things don't sort properly for real users with real data.