receta
Compare

Combinators

Combinators modify and combine comparators to create complex sorting logic from simple building blocks.

Overview

FunctionPurposeExample Use Case
compose(...comparators)Multi-level sortingSort by status, then priority, then date
reverse(comparator)Flip sort orderConvert ascending to descending
nullsFirst(comparator)Null items at startEmpty slots first
nullsLast(comparator)Null items at endEmpty slots last
withTiebreaker(primary, tiebreaker)Explicit two-level sortScore, then time

compose()

Combines multiple comparators into a single multi-level sort with priority ordering.

Signature

Signature
function compose<T>(...comparators: Comparator<T>[]): Comparator<T>

How It Works

Applies comparators in order. If the first returns 0 (equal), tries the second, and so on:

compose(
  comparatorA,  // Try A first
  comparatorB,  // If A says equal, try B
  comparatorC   // If B also says equal, try C
)

When to Use

  • Sorting by multiple fields with different priorities
  • "Sort by X, then by Y, then by Z" requirements
  • Data tables with multi-column sorting
  • Complex business logic sorting

Examples

GitHub Issues: Status → Priority → Date

import { compose, ascending, byDate } from '@ontologyio/receta/compare'

interface Issue {
  status: 'open' | 'closed'
  priority: 'low' | 'medium' | 'high'
  createdAt: Date
}

const statusOrder = { open: 0, closed: 1 }
const priorityOrder = { high: 0, medium: 1, low: 2 }

issues.sort(
  compose(
    ascending(i => statusOrder[i.status]),     // 1. Open first
    ascending(i => priorityOrder[i.priority]), // 2. Then high priority
    byDate(i => i.createdAt)                   // 3. Then oldest
  )
)
// Result: Open high-priority issues (oldest first), then open medium, then open low, then closed

Employee List: Department → Salary → Name

interface Employee {
  name: string
  department: string
  salary: number
}

employees.sort(
  compose(
    ascending(e => e.department),  // Group by department
    descending(e => e.salary),     // Within department, highest paid first
    ascending(e => e.name)         // Break ties by name
  )
)

E-commerce: Category → Price → Rating

interface Product {
  category: string
  price: number
  rating: number
}

products.sort(
  compose(
    ascending(p => p.category),    // Group by category
    ascending(p => p.price),       // Cheapest first within category
    descending(p => p.rating)      // Highest rated breaks ties
  )
)

File Browser: Type → Name

interface FileEntry {
  name: string
  type: 'directory' | 'file'
}

files.sort(
  compose(
    ascending(f => f.type === 'directory' ? 0 : 1), // Directories first
    natural(f => f.name)                            // Then natural sort by name
  )
)
// => [docs/, src/, package.json, README.md]

Real-World: Stripe Transactions

interface Transaction {
  status: 'succeeded' | 'pending' | 'failed'
  amount: number
  createdAt: Date
}

const statusPriority = { failed: 0, pending: 1, succeeded: 2 }

transactions.sort(
  compose(
    ascending(t => statusPriority[t.status]), // Failed first (need attention)
    descending(t => t.amount),                // Largest amounts first
    descending(t => t.createdAt)              // Most recent first
  )
)

reverse()

Reverses the order of any comparator.

Signature

Signature
function reverse<T>(comparator: Comparator<T>): Comparator<T>

When to Use

  • Toggle between ascending/descending based on user input
  • Invert an existing comparator without rewriting logic
  • "Reverse alphabetical" or "newest first" sorting

Examples

Toggle Sort Direction

import { reverse, ascending } from '@ontologyio/receta/compare'

function getSortComparator(direction: 'asc' | 'desc') {
  const base = ascending((p: Product) => p.price)
  return direction === 'desc' ? reverse(base) : base
}

// User clicks "Price ↑"
products.sort(getSortComparator('asc'))

// User clicks "Price ↓"
products.sort(getSortComparator('desc'))

Newest First

// Instead of writing descending(), you can reverse ascending()
const oldestFirst = byDate((e: CalendarEvent) => e.date)
const newestFirst = reverse(oldestFirst)

events.sort(newestFirst)

Reverse Alphabetical

const aToZ = ascending((u: User) => u.name)
const zToA = reverse(aToZ)

users.sort(zToA)
// => [Zara, Yuki, Xavier, ...]

Note on reverse() vs descending()

// These are equivalent:
reverse(ascending((x: Product) => x.price))
descending((x: Product) => x.price)

// Use descending() for clarity when building from scratch
// Use reverse() when you already have a comparator to invert

nullsFirst()

Wraps a comparator to place null items at the beginning.

It looks at the items being compared, not at a field inside them. To order objects by a nullable field, see Sorting by a Nullable Field.

Signature

Signature
function nullsFirst<T>(
  comparator: Comparator<Nullable<T>>
): Comparator<Nullable<T>>

How It Works

Checks for null/undefined before delegating to the wrapped comparator:

// Pseudocode:
if (a == null && b == null) return 0    // Both null = equal
if (a == null) return -1                // a is null = a comes first
if (b == null) return 1                 // b is null = b comes first
return innerComparator(a, b)            // Neither null = use inner comparator

The wrapped comparator's type still includes null, so its extractor needs a fallback, even though a null item never reaches it.

Array.prototype.sort and R.sort move undefined to the end without calling the comparator, so this only reorders null.

When to Use

  • Lists that hold null placeholders, such as empty slots or rows still loading
  • Missing entries should appear prominently

Examples

Empty Slots First

import { nullsFirst, ascending } from '@ontologyio/receta/compare'

const scores: (number | null)[] = [30, null, 10, null, 20]

scores.sort(nullsFirst(ascending(score => score ?? 0)))
// => [null, null, 10, 20, 30]

Objects That May Be Missing

interface Seat {
  label: string
}

const seats: (Seat | null)[] = [{ label: 'B2' }, null, { label: 'A1' }]

seats.sort(nullsFirst(ascending(seat => seat?.label ?? '')))
// => [null, { label: 'A1' }, { label: 'B2' }]

Sorting by a Nullable Field

When the items are objects and a field inside them can be null, nullsFirst has nothing to do: every item is non-null. Give the extractor a fallback that sorts where you want the nulls to land instead.

Unassigned Tasks First

import { ascending } from '@ontologyio/receta/compare'

interface Task {
  title: string
  assignee: string | null
}

const tasks: Task[] = [
  { title: 'Fix bug', assignee: 'alice' },
  { title: 'Review PR', assignee: null },
  { title: 'Write docs', assignee: 'bob' },
  { title: 'Deploy', assignee: null }
]

// An empty string sorts before every name
tasks.sort(ascending(t => t.assignee ?? ''))
// => [Review PR (null), Deploy (null), Fix bug (alice), Write docs (bob)]

Missing Data First

interface User {
  name: string
  lastLogin: Date | null
}

// Show users who never logged in first
users.sort(ascending(u => u.lastLogin ?? new Date(0)))

Optional Deadlines

interface Project {
  name: string
  deadline: Date | undefined
}

// Projects without deadlines shown first (need attention)
projects.sort(ascending(p => p.deadline ?? new Date(0)))

nullsLast()

Wraps a comparator to place null items at the end.

Like nullsFirst, it looks at the items being compared, not at a field inside them.

Signature

Signature
function nullsLast<T>(
  comparator: Comparator<Nullable<T>>
): Comparator<Nullable<T>>

When to Use

  • Lists that hold null placeholders you want out of the way
  • Focus on items with data

Examples

Empty Slots Last

import { nullsLast, ascending } from '@ontologyio/receta/compare'

const scores: (number | null)[] = [30, null, 10, null, 20]

scores.sort(nullsLast(ascending(score => score ?? 0)))
// => [10, 20, 30, null, null]

Sorting by a Nullable Field Last

For a nullable field, use a fallback again. With descending, the lowest possible value lands at the end.

Active Users First

import { descending } from '@ontologyio/receta/compare'

interface User {
  name: string
  lastActivity: Date | null
}

// Active users first, inactive (null) at end
users.sort(descending(u => u.lastActivity ?? new Date(0)))
// => [Most recent activity ... least recent ... null, null]

Products with Discounts

interface Product {
  name: string
  discount: number | null
}

// Products with discounts first, no discount at end
products.sort(descending(p => p.discount ?? 0))

Verified Accounts

interface Account {
  email: string
  verifiedAt: Date | null
}

// Verified accounts first, unverified at end
accounts.sort(descending(a => a.verifiedAt ?? new Date(0)))

Real-World: Linear Issues

When the field only decides a group, byBoolean says so directly, because false sorts before true:

interface LinearIssue {
  title: string
  assignee: { name: string } | null
  dueDate: Date | null
  priority: number
}

// Assigned issues first, then by priority
issues.sort(
  compose(
    byBoolean(i => i.assignee === null),
    descending(i => i.priority)
  )
)

withTiebreaker()

Combines a primary comparator with a tiebreaker for when values are equal. More explicit than compose() for two-level sorting.

Signature

Signature
function withTiebreaker<T>(
  primary: Comparator<T>,
  tiebreaker: Comparator<T>
): Comparator<T>

When to Use

  • Explicit two-level sorting (more readable than compose() for 2 levels)
  • Tiebreaking in games/competitions
  • Secondary sort criteria clearly defined

Examples

Leaderboard: Score → Time

import { withTiebreaker, descending, ascending } from '@ontologyio/receta/compare'

interface Player {
  name: string
  score: number
  completionTime: number
}

// Highest score wins, fastest time breaks ties
players.sort(
  withTiebreaker(
    descending(p => p.score),
    ascending(p => p.completionTime)
  )
)

Sales Report: Revenue → Region

interface SalesRegion {
  region: string
  revenue: number
}

// Highest revenue first, alphabetical breaks ties
regions.sort(
  withTiebreaker(
    descending(r => r.revenue),
    ascending(r => r.region)
  )
)

Job Applications: Experience → Education

interface Applicant {
  name: string
  yearsExperience: number
  educationLevel: number
}

// Most experience first, highest education breaks ties
applicants.sort(
  withTiebreaker(
    descending(a => a.yearsExperience),
    descending(a => a.educationLevel)
  )
)

withTiebreaker() vs compose()

// These are equivalent:
withTiebreaker(comparatorA, comparatorB)
compose(comparatorA, comparatorB)

// withTiebreaker is more explicit for 2-level sorting
// compose is better for 3+ levels

Combining Combinators

You can nest combinators for complex logic:

Example: Multi-Level with Null Handling

interface Task {
  status: 'open' | 'closed'
  assignee: string | null
  priority: number
  createdAt: Date
}

tasks.sort(
  compose(
    ascending(t => t.status === 'open' ? 0 : 1),      // Open first
    byBoolean(t => t.assignee !== null),              // Unassigned first within status
    descending(t => t.priority),                      // High priority first
    byDate(t => t.createdAt)                          // Oldest first
  )
)
// Open unassigned high-priority old tasks first!

Example: Reversing a Composed Comparator

const normalOrder = compose(
  ascending((p: Product) => p.category),
  ascending((p: Product) => p.price)
)

const reverseOrder = reverse(normalOrder)

// Completely reverses the entire sort
products.sort(reverseOrder)

Example: Nullable Values in Multi-Level Sort

interface Employee {
  department: string
  manager: string | null
  salary: number
}

employees.sort(
  compose(
    ascending(e => e.department),
    byBoolean(e => e.manager === null), // No manager goes last
    descending(e => e.salary)
  )
)

Comparison Table

CombinatorEffectUse When
compose(a, b, c)Multi-level sort3+ sort criteria
withTiebreaker(a, b)Primary + tiebreakExactly 2 levels (more explicit)
reverse(comparator)Flip orderToggle asc/desc
nullsFirst(comparator)Null items at startMissing entries need attention
nullsLast(comparator)Null items at endFocus on entries with data

Common Patterns

Pattern: User-Controlled Multi-Column Sort

interface SortConfig {
  column: string
  direction: 'asc' | 'desc'
}

function buildComparator(configs: SortConfig[]): Comparator<Item> {
  const comparators = configs.map(cfg => {
    const base = byKey<Item, keyof Item>(cfg.column as keyof Item)
    return cfg.direction === 'desc' ? reverse(base) : base
  })

  return compose(...comparators)
}

// User clicks: "Department ↑, Salary ↓, Name ↑"
const comparator = buildComparator([
  { column: 'department', direction: 'asc' },
  { column: 'salary', direction: 'desc' },
  { column: 'name', direction: 'asc' }
])

Pattern: Priority Queue

interface QueueItem {
  priority: number
  enqueuedAt: Date
}

// Higher priority first, FIFO breaks ties
queue.sort(
  withTiebreaker(
    descending(item => item.priority),
    ascending(item => item.enqueuedAt)
  )
)

Pattern: Grouped Sorting

interface Message {
  isStarred: boolean
  isRead: boolean
  receivedAt: Date
}

// Starred first, then unread, then by date
messages.sort(
  compose(
    reverse(byBoolean(m => m.isStarred)),
    byBoolean(m => m.isRead),
    descending(m => m.receivedAt)
  )
)

Next Steps

On this page