Usually, we sort things from small to large and vice versa or alphabetically.
But sometimes that's not enough. Things for sorting can look not comparable by regular criteria. How to sort moods or food, pets or weather? I'll show you one example close to the problem I was facing. And we'll figure out how to solve it using JS and TypeScript.
Imagine, you have a list of tasks with statuses "Success" | "Pending" | "Failed"
. And you have to show in the first place successful tasks, then failed ones, and finally pending tasks. It doesn't look like a direct or reverse alphabetical order. Take a look at a piece of data.
What we have:
[
{ "title": "Task one", "status": "Pending" },
{ "title": "Task two", "status": "Failed" },
{ "title": "Task three", "status": "Success" },
{ "title": "Task four", "status": "Pending" }
]
And what we want to get:
[
{ "title": "Task three", "status": "Success" },
{ "title": "Task two", "status": "Failed" },
{ "title": "Task one", "status": "Pending" },
{ "title": "Task four", "status": "Pending" }
]
Some sorting basics
First of all, let me remind you of some basics. How do we usually sort things in JS? Sure, we use Array.prototype.sort()
.
By default, it sorts in place in ascending order comparing elements as strings. If we want something non-standard, we have to pass compare function as a parameter: array.sort((a, b) => { /* … */ } )
. Compare function must return a number. For ascending order, it will be a positive number if a > b
, a negative number otherwise, and zero if they're equal:
array.sort((a, b) => {
if (a > b) return 1;
if (a < b) return -1;
return 0;
});
Naive solution
We can apply this common approach to our example. Remember, we want to achieve the order "Success"
-> "Failed"
-> "Pending"
. It means that for ascending order "Success" < "Failed"
, "Failed" < "Pending"
and "Success" < "Pending"
. According to the above rule, we have to return -1
(or any negative number) for all these combinations. For all opposite combinations it will be 1
(or any positive number):
tasks.sort((a, b) => {
if (
(a.status === "Success" && b.status === "Failed") ||
(a.status === "Failed" && b.status === "Pending") ||
(a.status === "Success" && b.status === "Pending")
) {
return -1;
}
if (
(a.status === "Failed" && b.status === "Success") ||
(a.status === "Pending" && b.status === "Failed") ||
(a.status === "Pending" && b.status === "Success")
) {
return 1;
}
return 0;
});
And it's exactly what I found in my colleague's PR and what inspired me to this article. Even so, it works and I respect my colleague. But would you be happy to maintain it, check each time all combinations, and remember what is less and larger?
Moreover, in this example, we have only 3 different statuses. It makes us write 3 combinations with 2 permutations for each one. But what happens if we have 5 statuses? According to math, we have to write 20 different combinations. Sounds insane, doesn't it? So, let's see what can be done better.
Array solution
The previous solution has many issues. It's not only a pile of logic operators, but also an implicit sequence of statuses that we used to define order.
And the first thing that comes to mind when I see the word "sequence" is the array. We can put all these statuses into the array and use their indexes for sorting as usual number sorting.
Back to the basics, numbers can be sorted using subtraction: numbers.sort((a, b) => a - b)
. And we only need to find the status indexes:
const statuses = ["Success", "Failed", "Pending"];
tasks.sort((a, b) => {
return statuses.indexOf(a.status) - statuses.indexOf(b.status);
});
Now we have an explicit order of statuses, it's easy to maintain. Our sorting code is also clear and doesn't depend on the status sequence. But we have another big issue. On each comparison iteration, we have to search in our statuses array, moreover, we have to do it twice. And it spoils performance. Nobody likes slow sites, so let's move forward.
Object literal solution
In the array, we needed to find statuses and get their indexes. Let's look at the previous statuses array as an object:
const statuses = {
0: "Success",
1: "Failed",
2: "Pending",
};
In fact, we used indexes as keys and statuses as values but needed to find statuses and get their indexes. So let's invert this logic:
const statusRanking = {
Success: 0,
Failed: 1,
Pending: 2,
};
tasks.sort((a, b) => {
return statusRanking[a.status] - statusRanking[b.status];
});
Now we have an explicit order of statuses and don't spoil the performance. In the array solution, we could rely on the array indexes, but here we have to write them ourselves. It's more flexible, but it's a potential place for bugs. For example, you can write some numbers twice or more, mix them up, and so on.
But you can say, that we can convert an array to an object. And you'll be right. It can be a compromise between these two solutions:
const statuses = ["Success", "Failed", "Pending"];
const statusRanking = statuses.reduce(
(ranking, status, statusIndex) =>
Object.assign(ranking, { [status]: statusIndex }),
{}
);
tasks.sort((a, b) => {
return statusRanking[a.status] - statusRanking[b.status];
});
Here we combined the explicit order of statuses and avoided unnecessary numbers. The only one disadvantage is the extra calculation in runtime.
Enum solution
If you're familiar with TS Enums, you can see in the object literal solution something similar. Our object looks like numeric enum with initializers:
enum Status {
Success = 0,
Failed = 1,
Pending = 2,
}
We can omit initializers and it will be initialized auto-incrementing from 0:
enum Status {
Success,
Failed,
Pending,
}
If you take a look at TS Playground and how this enum was compiled to JS, you can see the weird function. It looks unclear at first sight, but don't worry. All this function does is only create the object in runtime:
{
0: "Success",
1: "Failed",
2: "Pending",
Success: 0,
Failed: 1,
Pending: 2,
};
As you can see, it's a mixture of our two previous solutions. But we need only the second part with statuses as keys. Since an enum in runtime is an object, we can access enum members as the properties of an object. Square bracket notation can help us with it:
enum Status {
Success,
Pending,
Failed,
}
console.log(Status["Success"]);
And finally, apply it for sorting:
enum Status {
Success,
Pending,
Failed,
}
tasks.sort((a, b) => Status[a.status] - Status[b.status]);
Now the sorting order is clearly defined by an enum and the sorting code is concise enough.
Of course, it doesn't work for const
enums, because we need an object in runtime. So, be careful.
And one more point. If your "status" isn't one word ("In progress", for example), don't worry. Enum members can be defined like object keys:
enum Status {
Success,
"In progress",
Failed,
}
Our enum also can be used for defining the interface for a list of tasks:
interface Task {
title: string;
status: keyof typeof Status;
}
const tasks: Task[] = [
{ title: "Task one", status: "Pending" },
...
];
We have to use keyof typeof
to get the union type that represents all enum keys. The keyof
keyword doesn't work the same for enums as you might expect for typical objects.
Safe enum solution
In real life, we cannot completely rely on the received data. What happens if we meet a new status which is not in the Status enum? We can handle it and put our unknown status at the end of the sorted list:
tasks.sort(
(a, b) => (Status?.[a.status] ?? Infinity) - (Status?.[b.status] ?? Infinity)
);
Sorting as a reason to think about architecture
As other types of sorting, sorting with custom order is a resource-intensive operation. And it is another reason to think about delegating this operation to your data provider. If you're on the frontend side, answer yourself, can you get already sorted data from your backend? Or if you need to sort on the frontend side, can you receive sorting order or sorting criteria field? For example, you needn't enum for planets if their numbers are provided:
[
{"name": "Earth", "number": 3},
{"name": "Jupiter", "number": 5},
{"name": "Mercury", "number": 1}
]
If you're on the backend side, you also can avoid sorting with a custom order. Many modern databases have an enum data type. And they can return data according to the enum order using regular ORDER BY
. For example, PostgreSQL can do it by design. The same story with MySQL.
Conclusion
Of course, it would be better to receive already sorted data from the backend or DB. Or, at least, get data with a dedicated field stored sorting criterion. But sometimes sorting with custom order is unavoidable. In this case, TypeScript numeric enums can help to define sorting order. If you use plain JS or hate TS enums, try to use an object literal for ranking.
Top comments (2)
Excellent article and elegant solutions. Thanks!
I'm a bit late, but thanks anyway, Klim! I'm very glad that this article was useful to someone!