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 Value | Sort 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.