|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="utf-8"> |
| 5 | + <title>Composing sort rules using fp-ts</title> |
| 6 | + <link href="./assets/styles.css" rel="stylesheet"> |
| 7 | +</head> |
| 8 | +<body> |
| 9 | +<article> |
| 10 | +<p><a href="./index.html">← Back to home</a></p> |
| 11 | +<h1>Composing sort rules using fp-ts</h1> |
| 12 | +<p><strong>Date:</strong> October 10, 2025</p> |
| 13 | + <p>In this example I want to double down on the power of composition: the power of assembling complex rules from |
| 14 | + smaller, well-understood pieces and how that helps us define better boundaries between our modules. In this |
| 15 | + walkthrough we are using plain files, but in a more realistic setup each piece could happily live inside its own |
| 16 | + module in different directories.</p> |
| 17 | + <p>So let's start with a very common scenario that shows up across different kinds of applications: categories and |
| 18 | + products, and the rules that govern how we sort them for display.</p> |
| 19 | + <p>Our first step is to list the tools we have on hand so we keep our code cohesive without reinventing the wheel. |
| 20 | + For that we reach for the <code>Ord</code> module from fp-ts<sup><a href="#fn1">[1]</a></sup> along with a |
| 21 | + couple of essentials such as Monoids.</p> |
| 22 | + <p>This module lets us express ordering rules declaratively. Contrast that with what we usually find in |
| 23 | + applications: ad-hoc sorting logic scattered all over the place, reproduced multiple times, full of ternaries |
| 24 | + and <code>if</code>/<code>else</code> statements. Things get gnarly the moment complex, nested rules arrive, and |
| 25 | + maintaining or extending that code quickly becomes a nightmare.</p> |
| 26 | + <p>Enough “lero-lero”, let's see how this works in practice. First, let's lock down our acceptance |
| 27 | + criteria:</p> |
| 28 | + <pre><code class="language-markdown">**0001: Products must be ordered according to these rules:** |
| 29 | + |
| 30 | +1. Products must be sorted alphabetically by their `name`. |
| 31 | +2. Products that belong to the same category must stay together. |
| 32 | +3. Product categories must be ordered alphabetically by their `name`. |
| 33 | +</code></pre> |
| 34 | + <p>With that in mind we can start modeling the problem we want to solve. We face two entities: Product and Category. |
| 35 | + In this example the relationship is 1:1. We'll introduce them with interfaces, which lets us reuse them freely |
| 36 | + while abstracting away any concrete implementation details that do not matter right now.</p> |
| 37 | + <pre><code class="language-ts">interface Category { |
| 38 | + id: string |
| 39 | + name: string |
| 40 | +} |
| 41 | + |
| 42 | +interface Product { |
| 43 | + id: string |
| 44 | + name: string |
| 45 | + category: Category |
| 46 | + unavailable: boolean |
| 47 | +} |
| 48 | +</code></pre> |
| 49 | + <p>Now we can tackle each criterion in isolation.</p> |
| 50 | + <p>“Product categories must be ordered alphabetically by their <code>name</code> attribute.”</p> |
| 51 | + <p>We can read that as: a product entity has to follow the exact same ordering rule as its <code>name</code> |
| 52 | + property, which is a <code>string</code>. Great, we already know how to sort strings. Take a look at this piece |
| 53 | + of documentation from the fp-ts <code>string</code> module<sup><a href="#fn2">[2]</a></sup>:</p> |
| 54 | + <pre><code class="language-ts">import * as s from 'fp-ts/string' |
| 55 | + |
| 56 | +assert.deepStrictEqual(s.Ord.compare('a', 'a'), 0) |
| 57 | +assert.deepStrictEqual(s.Ord.compare('a', 'b'), -1) |
| 58 | +assert.deepStrictEqual(s.Ord.compare('b', 'a'), 1) |
| 59 | +</code></pre> |
| 60 | + <p>We can leverage <code>contramap</code> to hand that same behavior to both Category and Product. To keep things |
| 61 | + short, let's focus on Category first.</p> |
| 62 | + <pre><code class="language-ts">import * as s from 'fp-ts/string' |
| 63 | + |
| 64 | +const ordCategoriesAlphabetically: Ord.Ord<Category> = pipe( |
| 65 | + s.Ord, |
| 66 | + Ord.contramap((category: Category): string => category.name), |
| 67 | +) |
| 68 | +</code></pre> |
| 69 | + <p>Now we have an <code>Ord<Category></code> (and, by extension, an <code>Ord<Product></code>) that |
| 70 | + partially solves criteria 1 and 3. Next we have to handle criteria 2, which we can rephrase as: “Products |
| 71 | + must be ordered by category.” Once again we face the same shape of problem, we want a type to follow the |
| 72 | + ordering of one of its internal attributes. So we can reuse the exact same strategy:</p> |
| 73 | + <pre><code class="language-ts">import * as s from 'fp-ts/string' |
| 74 | + |
| 75 | +const ordProductsAlphabeticallyByCategory: Ord.Ord<Product> = pipe( |
| 76 | + s.Ord, |
| 77 | + Ord.contramap((product: Product) => product.category.name), |
| 78 | +) |
| 79 | +</code></pre> |
| 80 | + <p>Notice that while this works, we can simplify further because we already defined what it means to order |
| 81 | + categories by <code>name</code>. That gives us a healthier coupling between the two modules:</p> |
| 82 | + <pre><code class="language-ts">const ordProductsAlphabeticallyByCategory: Ord.Ord<Product> = pipe( |
| 83 | + ordCategoriesAlphabetically, |
| 84 | + Ord.contramap((product: Product) => product.category), |
| 85 | +) |
| 86 | +</code></pre> |
| 87 | + <p>Although we've addressed each requirement individually, and in a reusable way that extends beyond the original |
| 88 | + problem, we still need a mechanism to combine these rules so they deliver the result we actually want. This is |
| 89 | + where a remarkably powerful tool steps in: the Monoid<sup><a href="#fn3">[3]</a></sup>.</p> |
| 90 | + <p>Yes, the name sounds odd, but there's no reason to panic. Think of Monoids as pressure cookers: surprisingly easy |
| 91 | + to operate and capable of making your life a lot simpler.</p> |
| 92 | + <pre><code class="language-ts">import * as M from 'fp-ts/Monoid' |
| 93 | + |
| 94 | +const ordProducts: Ord.Ord<Product> = M.concatAll(Ord.getMonoid())([ |
| 95 | + ordProductsAlphabeticallyByCategory, |
| 96 | + ordProductsAlphabeticallyByName, |
| 97 | +]) |
| 98 | +</code></pre> |
| 99 | + <p>If currying is not your daily driver, we can rewrite the solution like this:</p> |
| 100 | + <pre><code class="language-ts">import * as M from 'fp-ts/Monoid' |
| 101 | + |
| 102 | +const combineMultipleOrds = M.concatAll(Ord.getMonoid()) |
| 103 | + |
| 104 | +const ordProducts: Ord.Ord<Product> = combineMultipleOrds([ |
| 105 | + ordProductsAlphabeticallyByCategory, |
| 106 | + ordProductsAlphabeticallyByName, |
| 107 | +]) |
| 108 | +</code></pre> |
| 109 | + <p>The result is still an <code>Ord<Product></code>, but now each criteria lives in isolation, making |
| 110 | + maintenance equally isolated. The implementation also becomes a semantic entry point for understanding the |
| 111 | + sorting rules. By combining them in a list we get a ranking view where we can quickly see which rule takes |
| 112 | + precedence. We can toggle them on or off with nothing more than a comment.</p> |
| 113 | + <p>Let's push this system a bit further by introducing a hypothetical feature. Imagine Products now have an <code>unavailable</code> |
| 114 | + boolean flag and a new acceptance criteria:</p> |
| 115 | + <pre><code class="language-markdown">**0002: Products must be ordered according to these rules:** |
| 116 | + |
| 117 | +1. They must follow the rules defined in #0001. |
| 118 | +2. Products marked as unavailable must appear at the end of their category list. |
| 119 | +</code></pre> |
| 120 | + <p>Following the same approach, we add a new <code>Ord<Product></code> and include it alongside the existing |
| 121 | + rules:</p> |
| 122 | + <pre><code class="language-ts">import * as b from "fp-ts/boolean"; |
| 123 | + |
| 124 | +const ordProductsByUnavailability: Ord.Ord<Product> = pipe( |
| 125 | + b.Ord, |
| 126 | + Ord.contramap((product: Product): boolean => product.unavailable), |
| 127 | +) |
| 128 | + |
| 129 | +// ... |
| 130 | + |
| 131 | +const ordProducts: Ord.Ord<Product> = M.concatAll(Ord.getMonoid())([ |
| 132 | + ordProductsAlphabeticallyByCategory, |
| 133 | + ordProductsByUnavailability, |
| 134 | + ordProductsAlphabeticallyByName, |
| 135 | +]) |
| 136 | +</code></pre> |
| 137 | + <p>Stop the machines! The users started complaining, the unavailable products buried the items they actually wanted |
| 138 | + to see. A new requirement pops up:</p> |
| 139 | + <pre><code class="language-markdown">**0003: Products must be ordered according to these rules:** |
| 140 | + |
| 141 | +1. This requirement deprecates #0002. |
| 142 | +</code></pre> |
| 143 | + <p>Done. We just disable the availability rule:</p> |
| 144 | + <pre><code class="language-ts">const ordProducts: Ord.Ord<Product> = M.concatAll(Ord.getMonoid())([ |
| 145 | + ordProductsAlphabeticallyByCategory, |
| 146 | + // ordProductsByUnavailability, |
| 147 | + ordProductsAlphabeticallyByName, |
| 148 | +]) |
| 149 | +</code></pre> |
| 150 | + <h2>Conclusion</h2> |
| 151 | + <p>Composition keeps our sorting logic friendly and ready for change. Each rule stays readable, the intent is |
| 152 | + obvious, and we can reuse behavior or toggle features with a quick comment. Monoids are still that pressure |
| 153 | + cooker from earlier: grab the handle, let them do the heavy lifting, and serve the result with zero stress. The |
| 154 | + next time someone asks for a fresh sorting tweak, just add it to the list and count on the combined rule set to |
| 155 | + keep everything tidy.</p> |
| 156 | + <hr> |
| 157 | + <section id="footnotes"> |
| 158 | + <h3>Footnotes</h3> |
| 159 | + <ol> |
| 160 | + <li id="fn1"><a href="https://github.com/gcanti/fp-ts">https://github.com/gcanti/fp-ts</a></li> |
| 161 | + <li id="fn2"><a href="https://gcanti.github.io/fp-ts/modules/string.ts.html#ord">https://gcanti.github.io/fp-ts/modules/string.ts.html#ord</a> |
| 162 | + </li> |
| 163 | + <li id="fn3"><a href="https://gcanti.github.io/fp-ts/modules/Monoid.ts.html#monoid-overview">https://gcanti.github.io/fp-ts/modules/Monoid.ts.html#monoid-overview</a> |
| 164 | + </li> |
| 165 | + </ol> |
| 166 | + </section> |
| 167 | +</article> |
| 168 | +</body> |
| 169 | +</html> |
0 commit comments