Overview
Build composable, type-safe comparators for sorting complex data structures. No more brittle array.sort((a, b) => ...) callbacks scattered throughout your codebase.
The Problem: Sorting is Harder Than It Looks
Sorting seems simple until you need to:
- Sort by multiple fields with different priorities
- Handle null/undefined values consistently
- Sort strings case-insensitively or with locale awareness
- Sort version numbers naturally ("v1.2" before "v1.10")
- Reverse sort directions without rewriting logic
- Compose sorting logic across your application
Real-World Example: GitHub Issues Sorting
Here's what you typically write:
interface Issue {
status: 'open' | 'closed'
priority: 'low' | 'medium' | 'high'
createdAt: Date
assignee: string | null
}
// ❌ Brittle, hard to read, easy to get wrong
issues.sort((a, b) => {
// Sort by status (open first)
const statusOrder = { open: 0, closed: 1 }
const statusDiff = statusOrder[a.status] - statusOrder[b.status]
if (statusDiff !== 0) return statusDiff
// Then by priority (high first)
const priorityOrder = { high: 0, medium: 1, low: 2 }
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority]
if (priorityDiff !== 0) return priorityDiff
// Then by created date (oldest first)
return a.createdAt.getTime() - b.createdAt.getTime()
})Problems:
- Not reusable - Can't extract "sort by priority" for other lists
- Error-prone - Easy to mess up the comparison logic
- Hard to read - Business logic buried in nested conditions
- Not composable - Can't build complex sorts from simple ones
- No type safety - Typos in property names caught only at runtime
How Successful Products Handle This
GitHub (actual UI sorting):
- Issues can be sorted by status, assignee, labels, milestones, created date, updated date
- Multi-level sorting: "Open issues assigned to me, sorted by priority, then by age"
- Nulls handling: Unassigned issues grouped separately
Stripe (transaction lists):
- Sort by amount, status, date, customer
- Natural sorting for invoice numbers ("INV-2" before "INV-10")
- Consistent null handling for optional fields
Vercel (deployment lists):
- Sort by date (most recent first), duration, status
- Case-insensitive sorting for branch names
- Compose filters + sorts in URL query params
All these products need reusable, composable sorting logic.
The Solution: Comparator Builders
import { ascending, compose, byDate } from '@ontologyio/receta/compare'
// ✅ Readable, reusable, composable
const statusOrder = { open: 0, closed: 1 }
const priorityOrder = { high: 0, medium: 1, low: 2 }
issues.sort(
compose(
ascending(i => statusOrder[i.status]),
ascending(i => priorityOrder[i.priority]),
byDate(i => i.createdAt)
)
)Benefits:
- Reusable - Extract
byPriority = ascending(i => priorityOrder[i.priority]) - Type-safe - TypeScript catches property name typos
- Readable - Intent is clear: "sort by status, then priority, then date"
- Composable - Build complex sorts from simple building blocks
- Testable - Test individual comparators in isolation
Why Compare Over Array.sort() Callbacks?
Problem 1: Array.sort() Doesn't Compose
// ❌ Can't reuse or combine
const sortByPrice = (a: Product, b: Product) => a.price - b.price
const sortByStock = (a: Product, b: Product) => a.stock - b.stock
// How do you combine these? Copy-paste and add if statements? 😢// ✅ Comparators compose naturally
import { ascending, compose } from '@ontologyio/receta/compare'
const byPrice = ascending((p: Product) => p.price)
const byStock = ascending((p: Product) => p.stock)
// Compose them!
const byPriceThenStock = compose(byPrice, byStock)
products.sort(byPriceThenStock)Problem 2: Array.sort() Loses Type Information
// ❌ No type safety
items.sort((a, b) => a.createdAd - b.createdAt) // Typo! Runtime error
// ✅ TypeScript catches typos
import { byDate } from '@ontologyio/receta/compare'
items.sort(byDate(i => i.createdAd)) // ❌ Compile error: Property 'createdAd' doesn't existProblem 3: Array.sort() Doesn't Work in Pipelines
// ❌ Have to break out of the pipe
const result = R.pipe(
items,
R.filter(isActive),
// Can't pass a comparator here easily
(items) => [...items].sort((a, b) => a.priority - b.priority)
)
// ✅ Works seamlessly with Remeda
import { ascending } from '@ontologyio/receta/compare'
const result = R.pipe(
items,
R.filter(isActive),
R.sort(ascending(i => i.priority))
)Problem 4: Array.sort() Hides Null Handling
// ❌ A null quietly counts as 0, so it lands among the real values
items.sort((a, b) => (a.value ?? 0) - (b.value ?? 0))
// ✅ Say where nulls go: false sorts before true
import { ascending, byBoolean, compose } from '@ontologyio/receta/compare'
items.sort(
compose(
byBoolean(i => i.value === null), // Items with no value go last
ascending(i => i.value ?? 0)
)
)Real-World Use Cases
1. Data Tables
Sort by multiple columns with different directions:
import { compose, ascending, descending } from '@ontologyio/receta/compare'
interface Employee {
department: string
name: string
salary: number
}
// Sort by department (asc), then salary (desc)
employees.sort(
compose(
ascending(e => e.department),
descending(e => e.salary)
)
)2. API Response Lists
Handle complex sorting from query params:
import { ascending, descending, byDate, compose } from '@ontologyio/receta/compare'
interface SearchParams {
sortBy: 'date' | 'relevance' | 'price'
order: 'asc' | 'desc'
}
function getSortComparator(params: SearchParams): Comparator<Product> {
const base = params.sortBy === 'date'
? byDate(p => p.createdAt)
: params.sortBy === 'price'
? ascending(p => p.price)
: ascending(p => p.relevanceScore)
return params.order === 'desc' ? reverse(base) : base
}3. File Browsers
Natural sorting for file names:
import { natural, compose, ascending } from '@ontologyio/receta/compare'
interface FileEntry {
type: 'file' | 'directory'
name: string
}
// Directories first, then natural sort by name
files.sort(
compose(
ascending(f => f.type === 'directory' ? 0 : 1),
natural(f => f.name)
)
)
// => [dir1/, dir2/, file1.txt, file2.txt, file10.txt]4. Leaderboards
Sort players by score, breaking ties by time:
import { withTiebreaker, descending, ascending } from '@ontologyio/receta/compare'
interface Player {
name: string
score: number
completionTime: number
}
// Highest score first, fastest time breaks ties
players.sort(
withTiebreaker(
descending(p => p.score),
ascending(p => p.completionTime)
)
)5. Internationalized Apps
Locale-aware string sorting:
import { localeCompare } from '@ontologyio/receta/compare'
const germanCities = ['München', 'Berlin', 'Zürich']
germanCities.sort(localeCompare(x => x, 'de-DE'))
// => Correctly handles ü, ö, ä according to German rulesMental Model: Building Blocks
Think of comparators as Lego bricks for sorting:
┌─────────────────────────────────────────────┐
│ Final Comparator │
│ (What you pass to array.sort()) │
└─────────────────────────────────────────────┘
▲
│
┌──────────┴──────────┐
│ │
┌────────┐ ┌───────────┐
│ Simple │ │ Combinator│
│ Block │ + │ (compose,│
│ │ │ reverse) │
└────────┘ └───────────┘
│ │
ascending() compose()
byDate() reverse()
natural() nullsFirst()Simple blocks do one thing:
ascending(fn)- Sort low to highbyDate(fn)- Sort dates chronologicallynatural(fn)- Natural string sorting
Combinators connect blocks:
compose(a, b, c)- Multi-level sortingreverse(comparator)- Flip the ordernullsFirst(comparator)- Put null items first
Combine them to solve complex problems:
compose(
ascending(x => statusOrder[x.status]),
reverse(byDate(x => x.createdAt)),
ascending(x => x.assignee ?? '')
)When to Use Compare
✅ Use when:
- Sorting objects by multiple fields
- Need consistent null/undefined handling
- Building reusable sorting utilities
- Working with internationalized strings
- Sorting version numbers or file names naturally
- Combining filters and sorts in pipelines
- Need type-safe property access
❌ Don't use for:
- Simple one-off number sorting (
[3,1,2].sort((a,b) => a - b)is fine) - Sorting primitives where built-in sort works
- When you need custom collation beyond locale support
- Performance-critical sorting (though Compare is fast, hand-written may be faster)
Core Concepts
1. Comparator Type
type Comparator<T> = (a: T, b: T) => number
// Returns:
// < 0 if a should come before b
// 0 if a and b are equal
// > 0 if a should come after bAll Compare functions either create or modify comparators.
2. Ascending vs Descending
ascending((x: Item) => x.value) // Low to high: 1, 2, 3
descending((x: Item) => x.value) // High to low: 3, 2, 13. Composition
// Primary sort, then tiebreaker, then second tiebreaker
compose(
primaryComparator,
tiebreakerComparator,
secondTiebreakerComparator
)4. Null Handling
nullsFirst(comparator) // null, null, 1, 2, 3
nullsLast(comparator) // 1, 2, 3, null, nullThese check the items being sorted, not a field inside them. For a nullable field, see Sorting by a Nullable Field.
5. Type-Specific Comparators
Different types need different comparisons:
- Dates:
byDate()compares timestamps - Numbers:
byNumber()handles NaN and Infinity - Strings:
byString()supports case and locale options - Booleans:
byBoolean()treats false < true
Comparison with Alternatives
vs Lodash _.orderBy
// Lodash
_.orderBy(users, ['status', 'priority', 'createdAt'], ['asc', 'desc', 'asc'])
// ❌ Problems:
// - Property names are strings (no type safety)
// - Limited to object properties (can't use computed values)
// - Order array must match property array
// - No composition or reusability
// Compare
users.sort(
compose(
ascending(u => u.status),
descending(u => u.priority),
byDate(u => u.createdAt)
)
)
// ✅ Type-safe, composable, works with any extraction functionvs Native Intl.Collator
// Intl.Collator (good for strings)
const collator = new Intl.Collator('de-DE')
names.sort((a, b) => collator.compare(a, b))
// ❌ Only works for strings, verbose for objects
// Compare (uses String.prototype.localeCompare)
names.sort(localeCompare(x => x, 'de-DE'))
// ✅ Works with objects, composable, cleaner syntaxvs Ramda R.sortBy
// Ramda
import { sortBy, prop } from 'ramda'
sortBy(prop('age'), users)
// ❌ Can only sort by one property
// ❌ Always ascending
// ❌ No type inference
// Compare
users.sort(ascending(u => u.age))
// ✅ Full TypeScript support
// ✅ Composable for multi-level sorting
// ✅ More control (ascending/descending/natural/etc)Architecture: How It Works
Extraction Functions
All basic comparators take an extraction function:
ascending((user: User) => user.age)
└──────┬──────────────────┘
│
Extracts comparable valueThis separates what to compare (the value) from how to compare (ascending/descending/natural).
Composition via Priority
compose() applies comparators in order, short-circuiting when one returns non-zero:
compose(
comparatorA, // If A says a ≠ b, use A's result
comparatorB, // If A says a = b, try B
comparatorC // If B also says equal, try C
)Null Safety Layer
nullsFirst and nullsLast wrap comparators to handle null items before delegating. nullsFirst(innerComparator) behaves like this:
(a, b) => {
if (a == null && b == null) return 0
if (a == null) return -1 // a comes first
if (b == null) return 1 // b comes first
return innerComparator(a, b)
}What's Next?
- Basic Comparators -
ascending,descending,natural,byKey - Combinators -
compose,reverse,nullsFirst,nullsLast - Type-Specific -
byDate,byNumber,byString,byBoolean - Advanced -
caseInsensitive,localeCompare - Patterns & Recipes - Real-world solutions
- Migration Guide - From
Array.sort()to Compare - API Reference - Complete function catalog