JS/TS custom sorting order or how to sort weird things

akabikov

Alexey Kabikov

Posted on March 15, 2023

JS/TS custom sorting order or how to sort weird things

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" }
]
Enter fullscreen mode Exit fullscreen mode

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" }
]
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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",
};
Enter fullscreen mode Exit fullscreen mode

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];
});
Enter fullscreen mode Exit fullscreen mode

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];
});
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

We can omit initializers and it will be initialized auto-incrementing from 0:

enum Status {
  Success,
  Failed,
  Pending,
}
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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"]);
Enter fullscreen mode Exit fullscreen mode

And finally, apply it for sorting:

enum Status {
  Success,
  Pending,
  Failed,
}

tasks.sort((a, b) => Status[a.status] - Status[b.status]);
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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" },
  ...
];
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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}
]
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
akabikov
Alexey Kabikov

Posted on March 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related