- Sort arrays of objects recursively by multiple keys.
- Keep it typed, and control asc/desc.
- Use it in Angular templates via Pipes.
- Use it in my Angular components, services, etc.
- Use it in rxjs pipes with a custom operator.
Sometimes you run into a problem that needs to be solved and you just can't find a good example of it. I ran into this issue where I wanted to sort arrays of objects by multiple keys, keep it type safe, and be able to use it easily from templates, code, and in rxjs pipes.
Initially, I was working on a Twitch Extension for polling viewers, and I wanted to be able to sort the results by vote counts, and the order I created them. I knew all of my data would be available on the client at once, and would be updating dynamically with PUBSUB as people vote. I didn't want to send the entire poll with each PUBSUB message, so needed a good way to sort it on the client on the fly. I've since used it in other projects as well for varying use cases.
// Errors.
const ERROR_REQUIRES_AT_LEAST_ONE_KEY = 'provide at least one key to sort by';
const ERROR_KEY_LENGTH_INVALID = 'a key was provided as an empty string';
const ERROR_DESC_KEY_LENGTH_INVALID = 'a descending key was missing the key name';
const ERROR_OBJECT_DOESNT_CONTAIN_KEY = 'a key you are attempting to sort by is not on all objects';
/**
* Recursive function to sort values by their keys.
*/
const sortByKey = <T>(a: T, b: T, ...keys: string[]): number => {
// Get first key in array.
let key = keys.shift();
// Make sure we have a valid key name.
if (!key.length) {
throw new Error(ERROR_KEY_LENGTH_INVALID);
}
// Default to ascending order.
let desc = false;
// Check for descending sort.
if (key.charAt(0) === '-') {
// Make sure key has a name as well as the minus sign.
if (key.length < 2) {
throw new Error(ERROR_DESC_KEY_LENGTH_INVALID);
}
// Remove minus from key name.
key = key.substr(1);
// Flag as descending order.
desc = true;
}
// Make sure the objects both have the key. We make sure
// to check this after we have removed the minus sign.
if (!a.hasOwnProperty(key) || !b.hasOwnProperty(key)) {
throw new Error(ERROR_OBJECT_DOESNT_CONTAIN_KEY);
}
// Determine checks based on asc / desc.
const direction = (desc) ? -1 : 1;
// Perform bubble sort based on the values.
if (a[key] > b[key]) {
return 1 * direction;
}
if (a[key] < b[key]) {
return -1 * direction;
}
// The values of the current key are equal, so if we still
// have keys to check recursively, check the next key.
if (keys.length) {
return sortByKey(a, b, ...keys);
}
// All keys returned and no more sorting needed.
return 0;
};
/**
* Wrapper sort function for the recursive one.
*/
export const sortByKeys = <T>(data: T[], ...keys: string[]): T[] => {
// Make sure we have at least one key to sort by.
if (!keys.length) {
throw new Error(ERROR_REQUIRES_AT_LEAST_ONE_KEY);
}
// Sort data.
data.sort((a: T, b: T): number => {
return sortByKey(a, b, ...keys);
});
// Return sorted data.
return data;
};
Being able to use generics in our functions allows us to keep everything type safe. I really wanted to avoid passing in an Interface
, and it coming back as some random object.
When two values are equal, we will recursively run the function again, but this time hopping to the next key in the sorting order until there are no more keys to compare.
Adding a very simple way to pick between ascending and descending order was important. I opted to add a minus sign -
as the first character to flag descending, and default to ascending.
If something isn't working, I want to know why. Maybe I forgot a key name, or I tried to sort a field that didn't exist. Throwing these errors allows us to catch them in our rxjs pipes as well and handle them gracefully.
Taking advantage of Angular's Pipes
, we can now use this function in our templates without having to worry about importing any code to our components.
import { Pipe, PipeTransform } from '@angular/core';
import { sortByKeys } from 'src/app/utils/sort-by-keys';
/**
* Pipe for the sorting function.
*/
@Pipe({
name: 'sortByKeys'
})
export class SortByKeysPipe implements PipeTransform {
public transform(value: any[], ...keys: string[]): any[] {
return sortByKeys<any>(value.slice(), ...keys);
}
}
And then in the templates, use it like so:
<table>
<tbody>
<tr *ngFor="let person of unsortedPeople | sortByKeys:'city':'lastName':'firstName'">
<td>{{ person.firstName }}</td>
<td>{{ person.lastName }}</td>
<td>{{ person.city }}</td>
<td>{{ person.votes }}</td>
</tr>
</tbody>
</table>
This example will always sort by city, then last name, and finally first name - all in ascending order.
Using the functionality in your code is super straight forward, and can be used in places like a ngOnInit
, Input
setters, or in your reducers
. Some examples:
public ngOnInit(): void {
// Sort the people on the component.
this.sortedPeople = sortByKeys(people, '-city', '-lastName', '-firstName');
}
@Input()
public set people(people: Person[]) {
this._people = sortByKeys(people, '-city', '-lastName', '-firstName');
}
public get people(): Person[] {
return this._people;
}
private _people: Person[] = [];
Reactive code is awesome. If you want to use this in your rxjs pipes, you can make a custom operator that wraps the function for you. This will take in the Observable
, sort it, and return a new sorted Observable
of the same type.
import { map } from 'rxjs/operators';
import { sortByKeys } from './sort-by-keys';
/**
* Custom operator for the sorting function.
*/
export const sortKeys = <T>(...keys: string[]) => map(
(x: T[]): T[] => sortByKeys(x, ...keys)
);
This code is quite minimal since we are able to rely on the already existing map
operator. Optionally, we could use a more explicit approach like this one:
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { sortByKeys } from './sort-by-keys';
export const sortKeys = <T>(...keys: string[]) => (source: Observable<T[]>): Observable<T[]> =>
new Observable(observer => {
return source.subscribe({
next(x) {
observer.next(
sortByKeys(x, ...keys)
);
},
error(err) { observer.error(err); },
complete() { observer.complete(); }
});
});
Using the operator in a pipe becomes ezpz:
// Service that returns an Obserable.
this.myService.getData().pipe(
// Custom operator.
sortKeys('-votes', 'firstName', 'lastName'),
).subscribe(...);
And then watch your sorting happen as new data comes in:
Yep. All the arrays I needed this for are fairly small, so no need to use a crazy algorythm here.
True. If you are paginating the data, sorting on the client doesn't make any sense. Do it on the server / database instead.
I made an Angular app on Github here that shows some examples. You can also check it out on Stack Blitz here as well.
Know a better way to do this? Have you done something similar? Tell me in the comments below!