This repository shows how to model, derive, and compose ordering (sorting) rules using the Ord
abstractions from fp-ts
. The focus is on expressing multi-criteria sorting declaratively and incrementally reusing smaller building blocks.
Traditional sorting code tends to be ad‑hoc: one long comparison function with nested if
/ else
branches. That approach is:
- Hard to extend (adding a new tie‑breaker risks breaking existing logic)
- Hard to test in isolation (all concerns tangled together)
- Imperative (focuses on control flow instead of intent)
fp-ts/Ord
lets us:
- Start with primitive orderings (e.g. built‑in
string
ordering) - Lift them to our domain types via
contramap
- Combine many
Ord
instances (lexicographically) with theMonoid
forOrd
- Reuse previously defined orderings across domains
Result: Each rule states what we sort by, not how to juggle comparisons.
interface Category {
readonly id: string;
readonly name: string
}
interface Product {
readonly id: string;
readonly name: string;
readonly category: Category;
}
(See the real definitions in src/categories/category.ts
and src/products/product.ts
).
We start from the canonical string
ordering exported as s.Ord
:
import * as s from 'fp-ts/string';
// s.Ord : Ord<string>
contramap
lets us focus an Ord<A>
onto a structure B
by providing a projection B -> A
.
import * as Ord from 'fp-ts/Ord';
import { pipe } from 'fp-ts/function';
import * as s from 'fp-ts/string';
// Category ordering by name
export const ordCategoriesAlphabetically = pipe(
s.Ord,
Ord.contramap((c: Category) => c.name),
);
// Product ordering by *its own* name
export const ordProductsAlphabeticallyByName = pipe(
s.Ord,
Ord.contramap((p: Product) => p.name),
);
// Product ordering by *its category* (reusing Category ordering!)
export const ordProductsAlphabeticallyByCategory = pipe(
ordCategoriesAlphabetically,
Ord.contramap((p: Product) => p.category),
);
Notice how ordProductsAlphabeticallyByCategory
reuses the category ordering—if later we change how categories are ordered (e.g. case‑insensitive), every dependent ordering benefits automatically.
We often need “sort by Category name, then by Product name”. Instead of hand‑writing branching logic, we use the Monoid
for Ord
which combines orderings lexicographically:
import { concatAll } from 'fp-ts/Monoid';
export const ordProducts = concatAll(Ord.getMonoid<Product>())([
ordProductsAlphabeticallyByCategory, // primary key
ordProductsAlphabeticallyByName, // secondary (tie‑breaker)
]);
How it works:
- The Monoid’s
concat
tries the firstOrd
. - If it returns
0
(values considered equal w.r.t. that criterion), it delegates to the next. - This yields a left‑to‑right lexicographic chain you can extend safely.
Need a deterministic final rule? Add an Ord
on id
:
export const ordProductsAlphabeticallyById = pipe(
s.Ord,
Ord.contramap((p: Product) => p.id),
);
export const ordProductsStable = concatAll(Ord.getMonoid<Product>())([
ordProductsAlphabeticallyByCategory,
ordProductsAlphabeticallyByName,
ordProductsAlphabeticallyById, // ensures a total, deterministic ordering
]);
No existing code modified—only appended.
Example usage in plain TypeScript:
import { ordProducts } from './src/products';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
// Suppose we have an in‑memory list
const products: Product[] = [ /* ... */ ];
// Turn the Ord into a comparison function for native Array#sort
const sortedNative = [...products].sort(ordProducts.compare);
// Or stay purely functional using fp-ts Array utilities
const sortedFunctional = pipe(products, A.sort(ordProducts));
Concept | Role |
---|---|
Ord<A> |
Describes total ordering for A |
contramap |
Derives an Ord<B> from Ord<A> + B -> A |
Monoid for Ord |
Lexicographically chains multiple orderings |
Reuse | Small Ord blocks compose; changes propagate |
- Declarative: Each rule is a tiny, intention‑revealing value.
- Extensible: New criteria = append to the list, not edit internals.
- Reusable: Category ordering reused inside product ordering.
- Testable: Each
Ord
can be property‑tested (antisymmetry, transitivity, totality). - Maintainable: Change a projection once; consumers automatically adopt it.
File | Key Exports |
---|---|
src/categories/category.ts |
Category , ordCategoriesAlphabetically |
src/products/product.ts |
Product , ordProductsAlphabeticallyByName , ordProductsAlphabeticallyByCategory , ordProducts |
- Case‑insensitive ordering: wrap
c.name.toLocaleLowerCase()
. - Locale / collation awareness via
Intl.Collator
to handle diacritics. - Optional / nullable fields: compose with
Ord
instances that pushnull
to start/end. - Reverse ordering:
Ord.reverse(ordProducts)
for descending variants. - Derive ordering for tuples:
Ord.tuple(ordA, ordB, ...)
when structure already decomposes.
- Add property‑based tests with
fast-check
(not yet included) to assert ordering laws. - Provide a benchmark comparing chained
Ord
vs bespoke comparator. - Publish as a small package exposing reusable helpers for common multi‑criteria patterns.
Install dependencies:
npm install
(No runtime scripts are defined—import the source in your own project or REPL.)
- Lexicographic Composition: Compare by first criterion; if equal, move to next.
- Projection (aka accessor): A function extracting the primitive value used for ordering.
- Total Ordering: Every pair of elements is comparable (no incomparable states).