receta
Compare

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:

  1. Not reusable - Can't extract "sort by priority" for other lists
  2. Error-prone - Easy to mess up the comparison logic
  3. Hard to read - Business logic buried in nested conditions
  4. Not composable - Can't build complex sorts from simple ones
  5. 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:

  1. Reusable - Extract byPriority = ascending(i => priorityOrder[i.priority])
  2. Type-safe - TypeScript catches property name typos
  3. Readable - Intent is clear: "sort by status, then priority, then date"
  4. Composable - Build complex sorts from simple building blocks
  5. 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 exist

Problem 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 rules

Mental 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 high
  • byDate(fn) - Sort dates chronologically
  • natural(fn) - Natural string sorting

Combinators connect blocks:

  • compose(a, b, c) - Multi-level sorting
  • reverse(comparator) - Flip the order
  • nullsFirst(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 b

All 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, 1

3. 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, null

These 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 function

vs 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 syntax

vs 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 value

This 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?

On this page