Vidal Vasconcelos
← Back to home

Composing sort rules using fp-ts

In this example I want to double down on the power of composition: the power of assembling complex rules from smaller, well-understood pieces and how that helps us define better boundaries between our modules. In this walkthrough we are using plain files, but in a more realistic setup each piece could happily live inside its own module in different directories.

So let's start with a very common scenario that shows up across different kinds of applications: categories and products, and the rules that govern how we sort them for display.

Our first step is to list the tools we have on hand so we keep our code cohesive without reinventing the wheel. For that we reach for the Ord module from fp-ts along with a couple of essentials such as Monoids.

This module lets us express ordering rules declaratively. Contrast that with what we usually find in applications: ad-hoc sorting logic scattered all over the place, reproduced multiple times, full of ternaries and if/else statements. Things get gnarly the moment complex, nested rules arrive, and maintaining or extending that code quickly becomes a nightmare.

Enough preamble, let's see how this works in practice. Say we need to sort a list of products so that they are ordered alphabetically by name, grouped by category, and the categories themselves are also alphabetical. Three small rules that, together, define the entire sorting behavior.

With that in mind we can start modeling the problem we want to solve. We face two entities: Product and Category. In this example the relationship is 1:1. We'll introduce them with interfaces, which lets us reuse them freely while abstracting away any concrete implementation details that do not matter right now.

interface Category {
  id: string
  name: string
}

interface Product {
  id: string
  name: string
  category: Category
  unavailable: boolean
}

Now we can tackle each criterion in isolation.

“Product categories must be ordered alphabetically by their name attribute.”

We can read that as: a category entity has to follow the exact same ordering rule as its name property, which is a string. Great, we already know how to sort strings. Take a look at this piece of documentation from the fp-ts string module:

import * as s from 'fp-ts/string'

assert.deepStrictEqual(s.Ord.compare('a', 'a'), 0)
assert.deepStrictEqual(s.Ord.compare('a', 'b'), -1)
assert.deepStrictEqual(s.Ord.compare('b', 'a'), 1)

We can leverage contramap to hand that same behavior to both Category and Product. To keep things short, let's focus on Category first.

import * as s from 'fp-ts/string'

const ordCategoriesAlphabetically: Ord.Ord<Category> = pipe(
  s.Ord,
  Ord.contramap((category: Category): string => category.name),
)

Now we have an Ord<Category> that partially solves criteria 1 and 3. Next we have to handle criteria 2, which we can rephrase as: “Products must be ordered by category.” Once again we face the same shape of problem, we want a type to follow the ordering of one of its internal attributes. So we can reuse the exact same strategy:

import * as s from 'fp-ts/string'

const ordProductsAlphabeticallyByCategory: Ord.Ord<Product> = pipe(
  s.Ord,
  Ord.contramap((product: Product) => product.category.name),
)

Here is a visual summary of what just happened. Each contramap call lifts the primitive Ord<string> into a domain-level ordering, and we can chain that lifting to reuse Ord<Category> inside Ord<Product>:

Diagram showing how contramap lifts Ord from string to Category and Product types, with arrows representing each contramap call and a reuse path from Ord Category to Ord Product

Notice that while this works, we can simplify further because we already defined what it means to order categories by name. That gives us a healthier coupling between the two modules:

const ordProductsAlphabeticallyByCategory: Ord.Ord<Product> = pipe(
  ordCategoriesAlphabetically,
  Ord.contramap((product: Product) => product.category),
)

Although we've addressed each requirement individually, and in a reusable way that extends beyond the original problem, we still need a mechanism to combine these rules so they deliver the result we actually want. This is where a remarkably powerful tool steps in: the Monoid.

Yes, the name sounds odd, but there's no reason to panic. Think of Monoids as pressure cookers: surprisingly easy to operate and capable of making your life a lot simpler.

import * as M from 'fp-ts/Monoid'

const ordProducts: Ord.Ord<Product> = M.concatAll(Ord.getMonoid())([
  ordProductsAlphabeticallyByCategory,
  ordProductsAlphabeticallyByName,
])

If currying is not your daily driver, we can rewrite the solution like this:

import * as M from 'fp-ts/Monoid'

const combineMultipleOrds = M.concatAll(Ord.getMonoid())

const ordProducts: Ord.Ord<Product> = combineMultipleOrds([
  ordProductsAlphabeticallyByCategory,
  ordProductsAlphabeticallyByName,
])

The result is still an Ord<Product>, but now each criterion lives in isolation, making maintenance equally isolated. The implementation also becomes a semantic entry point for understanding the sorting rules. By combining them in a list we get a ranking view where we can quickly see which rule takes precedence. We can toggle them on or off with nothing more than a comment.

Let's push this system a bit further. Imagine a new feature lands: products now carry an unavailable boolean flag, and the ones marked as unavailable must appear at the end of their category list. Following the same approach, we add a new Ord<Product> and include it alongside the existing rules:

import * as b from "fp-ts/boolean";

const ordProductsByUnavailability: Ord.Ord<Product> = pipe(
  b.Ord,
  Ord.contramap((product: Product): boolean => product.unavailable),
)

// ...

const ordProducts: Ord.Ord<Product> = M.concatAll(Ord.getMonoid())([
  ordProductsAlphabeticallyByCategory,
  ordProductsByUnavailability,
  ordProductsAlphabeticallyByName,
])

Here is the full picture. Each rule is an independent Ord<Product> that feeds into M.concatAll — their position in the list defines precedence, and toggling a rule is as simple as commenting it out:

Diagram showing individual Ord rules flowing into M.concatAll with Ord.getMonoid to produce a single combined Ord Product, with the unavailability rule shown as toggled off and a preview of the sorted output at the bottom

Stop the machines! Users started complaining that unavailable products buried the items they actually wanted to see. The unavailability rule has to go. Done — we just comment it out:

const ordProducts: Ord.Ord<Product> = M.concatAll(Ord.getMonoid())([
  ordProductsAlphabeticallyByCategory,
  // ordProductsByUnavailability,
  ordProductsAlphabeticallyByName,
])

Conclusion

Composition keeps our sorting logic friendly and ready for change. Each rule stays readable, the intent is obvious, and we can reuse behavior or toggle features with a quick comment. Monoids are still that pressure cooker from earlier: grab the handle, let them do the heavy lifting, and serve the result with zero stress. The next time someone asks for a fresh sorting tweak, just add it to the list and count on the combined rule set to keep everything tidy.