6 min read

Rewriting Fluent Sort

The native JS API for sorting is a little lacking, so I wrote a thing to fix that.
Rewriting Fluent Sort

I wrote an npm module for sorting arrays awhile back, fluent-sort, because hand writing nested sorting rules is kind of annoying (i.e., sort by rank and then sort alphabetically). The original fluent-sort was kind of junky, because it wasn't really a true fluent interface, and it wasn't really based on pure functions fully either.

All of fluent-sort was inspired by the simple syntax of C#:

class Pet
{
    public string Name { get; set; }
    public int Age { get; set; }
}

public static void OrderBy()
{
    Pet[] pets = { new Pet { Name="Barley", Age=8 },
                   new Pet { Name="Boots", Age=8 },
                   new Pet { Name="Whiskers", Age=1 } };

    IEnumerable<Pet> query = pets.OrderBy(pet => pet.Age).ThenByDescending(pet => pet.Name);

    foreach (Pet pet in query)
    {
        Console.WriteLine("{0} - {1}", pet.Name, pet.Age);
    }
}

/*
 This code produces the following output:

 Whiskers - 1
 Boots - 8	
 Barley - 8
*/
The above code was copied and modified slightly from the Microsoft documentation page on OrderBy.

JavaScript would look something like this:

class Pet {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}

const pets = [
  new Pet("Barley", 8),
  new Pet("Boots", 8),
  new Pet("Whiskers", 1)
];

const orderedPets = pets.sort((left, right) => {
  if (left.age === right.age) {
    if (left.name === right.age) return 0;

    return left.name > right.name ? -1 : 1;
  }

  return left.age > right.age ? 1 : -1;
});

console.log(orderedPets);

/* 
[ Pet { name: 'Whiskers', age: 1 },
  Pet { name: 'Boots', age: 8 },
  Pet { name: 'Barley', age: 8 } ]
*/

This is, frankly, terrible and dumb. Why am I returning -1 or 1? When do I return either? Why do I have to have that cognitive overload each time I need to sort?


Original release of fluent-sort

The original release of fluent-sort was suffering from an identity crises. It had a somewhat fluent syntax, and tried to occasionally be pure, and returned too many different classes just to make something have to call SortBy before ThenBy. It tried too hard to maintain compatibility with JavaScript's sort method signature.

const sortedTests = fluentSort(testCases) // Returns the OrderableInitiator
    .sortByField(x => x.intelligence) // Returns the Orderable
    .thenByFieldDescending(y => y.agility) // Returns a new Orderable with both rules applied
    .result(); // Evaluates and returns the result

console.log(sortedTests);
What even is this data structure? Why do I have to learn more terms just to make sorting easier? Too many options. Is .result() pure? What's happening?

There was nothing inherently wrong with any of that, but the original fluent-sort was trying too hard to keep the original sort method logic at the forefront of the library, and focused entirely too much on fancy method names. With fluent-sort's v2 release, I shifted the focus to the far more common concept of sorting by fields on an object, and making fluent-sort, well, actually fluent.


API Changes

The original version of fluent-sort returned specific classes called Orderables and OrderableInitiators, which allowed those objects to only be useful for sorting. They did not expose the underlying array, but rather internally stored the array as a class property. That means that you were no longer dealing with arrays, and were adding a great deal of overhead just for the sake of performing a sort. It wasn't wrong, but it wasn't quite right, either.

It was just unecessary.

Version 2 of fluent-sort takes an entirely different approach: FluentSortArrays extend native JavaScript arrays, allowing you to modify the array, check its array, and perform array methods on it like so:

const { FluentSortArray } = require("fluent-sort/es5/");

const testNumbers = new FluentSortArray(9, 8, 3, 5);
console.log(testNumbers.length);

testNumbers.push(12);
console.log(testNumbers.length);

const orderedEvenNumbers = testNumbers
  .sortBy(x => x)
  .executeCompositeSort()
  .filter(x => x % 2 === 0);
console.log(orderedEvenNumbers.join(", "));
Now, all operations are performed on real arrays, and you can setup sorts before running them, and still continue to mutate the original array before sorting it.

Which logs:

4
5
8, 12

This is much more in line with the concept of a fluent API.


Using fluent-sort

Importing

es6 with imports

You can import FluentSortArray from the root of the module, like so:

import FluentSortArray from "fluent-sort";

es5 / nodejs without imports

If you're still working in an ES5 environment and can't rely on the import/export syntax, then you can require FluentSortArray like so:

const { FluentSortArray } = require("fluent-sort/es5/");

In Browser

A preminified version of fluent-sort is packaged in node_modules/fluent-sort/dist/fluentSort.min.js

Add that script to your page, and then you can use FluentSortArray on your page.

Constructing a FluentSortArray

The FluentSortArray  extends the native Array class, so it has the same constructor. There are a number of ways to initialize a FluentSortArray:

const fromClassConstructor = new FluentSortArray(1,2,3); //equivalent to [1,2,3]
const fromSpreadingAnotherArray = new FluentSortArray(...[1,2,3]); // equivalent to above, technically, but may be preferable to write for you

const regularArr = [1,2,3];
const fromRegularArrWithoutMutatingRegularArr = FluentSortArray.fromArray(regularArr); // copies regularArr and constructs a new array

const getsMutated = [1,2,3];
const byMutatingRegularArray = FluentSortArray.makeFluent(getsMutated); // mutates getsMutated into a FluentSortArray; (getsMutated === byMutatingRegularArray) === true

All of these FluentSortArrays, by nature of extending array, provide all the normal array methods like slice, push, pop, filter, etc. and can be mutated at any time, and elements can be accessed by index like normal.

Chaining Sorting Rules

The assumption is that you will mostly be sorting by fields:

const testCases = [
  {
    id: 0,
    name: "Strong Monster",
    strength: 10,
    agility: 5,
    intelligence: 8,
    monsterdexOrder: 5
  },
  {
    id: 1,
    name: "Fast Monster",
    strength: 5,
    agility: 10,
    intelligence: 5,
    monsterdexOrder: 1
  },
  {
    id: 2,
    name: "Mediocre Monster",
    strength: 7.5,
    agility: 7.5,
    intelligence: 8,
    monsterdexOrder: 6
  },
  {
    id: 3,
    name: "Unimpressive Monster",
    strength: 2,
    agility: 2,
    intelligence: 2,
    monsterdexOrder: 4
  },
  {
    id: 4,
    name: "Slow Monster",
    strength: 7.5,
    agility: 3,
    intelligence: 8,
    monsterdexOrder: 17
  },
  {
    id: 5,
    name: "Smart Monster",
    strength: 3,
    agility: 7.5,
    intelligence: 15,
    monsterdexOrder: 75
  }
];

const sortedTests = new FluentSortArray(...testCases) // Spreads the array to construct a new FluentSortArray using native Array constructor syntax
  .sortBy(x => x.intelligence) // Returns the extended array
  .thenBy(y => y.agility) // Returns the same extended array
  .executeCompositeSort(); // Performs the sort and returns the extended array

You can add any number of thenBy statements. Each time you add a sortBy statement, you will start a brand new set of rules and disregard all rules seen previously.

The following methods are supported on each instance of a FluentSortArray:

Method Description
sortBy(selector) Sets a new sort order with selector as the initial field to sort on; sorts in ascending order
sortByAscending(selector) Identitcal to sortBy(selector)
sortByDescending(selector) Identical to sortBy(selector) but in descending order
sortComparing(comparator) Sets a new sort order with a native JS sort comparator function (return 0, -1, or 1) as the initial value to rank an element as
thenBy(selector) Adds another rule to sort on, if the previous sorting rule resulted in an identical value across two elements.
thenByAscending(selector) Identical to thenBy(selector)
thenByDescending(selector) Identical to thenBy(selector) but in descending order
thenComparing(comparator) Adds a new sort order with a native JS sort comparator function (return 0, -1, or 1) that will be evaluated if the previous sort rule returned an identical value across two elements.
executeCompositeSort() Performs the sort with all rules configured and returns the sorted array. This sort occurs in-place and modifies the instance you are working on.

Each method returns the instance you are configuring, allowing you to chain multiple statements easily:

const sortedTests = new FluentSortArray(...testCases) 
  .sortBy(x => x.intelligence)
  .thenBy(y => y.agility) 
  .executeCompositeSort(); 

Lessons Learned

Building

Many environments still do not support import / export syntax, so I configured babel to build out a special version that should work all the way down to ES5. I wanted to release with fluent-sort v2 the following:

  1. The original import / export / ES6 syntax version with all its features, copied into the root of the published package
  2. A babel-configured ES5 version that is stored in an /es5 directory
  3. A webpack bundled library that creates a fluentSort.min.js version stored in the /dist directory for browsers to use

This was quite annoying, and I did not want those artifacts in the repository. I wanted people to be able to easily install the package and use it

Publishing

I configured the package.json folder with some tasks to accomplish all this.

{
  "main": "index.js",
  "scripts": {
    "clean": "rm -rf build && mkdir build",
    "copy": "rsync -a -r src/ readme.md package.json package-lock.json build --exclude=\"*.test.js\"",
    "build": "webpack && babel src --out-dir build/es5",
    "bundle": "npm run clean && npm run build && npm run copy",
    "deploy": "npm run bundle && npm publish ./build"
  }
}
Others scripts / fields removed due to irrelevancy

This results in the following build folder to be generated and published to npm:

build
├── dist
│   ├── fluentSort.min.js
│   └── pureSort.min.js
├── es5
│   ├── index.js
│   ├── pure.js
│   └── utils
│       └── index.js
├── index.js
├── package-lock.json
├── package.json
├── pure.js
├── readme.md
└── utils
    └── index.js
Curious about pureSort and pure.js? See below for an explanation.

The Path Forward

I'm not done with this library just yet, despite it being basically feature complete for now. The next goals are:

  • Improve test coverage, and come up with better tests.
  • Create a demo website allowing users to modify objects and construct sorts, then see the code generated and the resulting sort order.
  • There is actually a (maybe not ready) pure version of this class provided as well that needs to be better tested, PureFluentSortArray, which constructs and returns a new object each time you add a sort rule. This requires testing and documentation.