Predicate
Migration Guide
From Inline Filters to Predicates
Step-by-step guide to refactoring existing code to use composable predicates.
Why Migrate?
Before: Inline Filters
// โ Problems:
const activeAdults = users.filter(u => u.age >= 18 && u.active === true)
const activeStaff = users.filter(u => u.age >= 18 && u.active === true && (u.role === 'admin' || u.role === 'moderator'))
const premiumUsers = users.filter(u => u.age >= 18 && u.active === true && u.premium)Issues:
- ๐ด Duplication: "age >= 18 && active === true" repeated 3 times
- ๐ด Not reusable: Can't extract or compose logic
- ๐ด Hard to test: Logic buried in larger functions
- ๐ด Not discoverable: No central place for filters
- ๐ด Type unsafe: No narrowing for mixed arrays
After: Predicates
// โ
Benefits:
import { where, and, gte, eq, oneOf } from '@ontologyio/receta/predicate'
const isActiveAdult = where({ age: gte(18), active: eq(true) })
const isStaff = where({ role: oneOf(['admin', 'moderator']) })
const activeAdults = R.filter(users, isActiveAdult)
const activeStaff = R.filter(users, and<User>(isActiveAdult, isStaff))
const premiumUsers = R.filter(users, and<User>(isActiveAdult, where({ premium: eq(true) })))Wins:
- โ No duplication: Define once, use everywhere
- โ Composable: Build complex from simple
- โ Testable: Test predicates in isolation
- โ Discoverable: All filters in one module
- โ Type safe: Type guards narrow types
Migration Strategy
Phase 1: Identify Patterns
Look for:
- Repeated conditions across multiple filters
- Complex boolean logic (multiple && or ||)
- Type checks (typeof, instanceof)
- Value comparisons (>, <, ===, includes())
- Object property checks (multiple property tests)
Phase 2: Extract Simple Predicates
Start with simple, reusable pieces.
Phase 3: Compose Complex Predicates
Combine simple predicates into complex ones.
Phase 4: Refactor Call Sites
Replace inline filters with named predicates.
Step-by-Step Examples
Example 1: Simple Numeric Filter
Before
const expensiveProducts = products.filter(p => p.price > 100)
const budgetProducts = products.filter(p => p.price <= 50)
const midRangeProducts = products.filter(p => p.price >= 50 && p.price <= 200)Step 1: Identify Pattern
- Repeated:
p.pricecomparisons - Pattern: Numeric threshold checks
Step 2: Extract Predicates
import { gt, lte, between } from '@ontologyio/receta/predicate'
const isExpensive = gt(100)
const isBudget = lte(50)
const isMidRange = between(50, 200)Step 3: Refactor
const expensiveProducts = R.filter(products, (p) => isExpensive(p.price))
const budgetProducts = R.filter(products, (p) => isBudget(p.price))
const midRangeProducts = R.filter(products, (p) => isMidRange(p.price))
// Or with where():
const expensiveProducts2 = R.filter(products, where({ price: gt(100) }))
const budgetProducts2 = R.filter(products, where({ price: lte(50) }))
const midRangeProducts2 = R.filter(products, where({ price: between(50, 200) }))Example 2: Status Filtering
Before
const activeOrders = orders.filter(o =>
o.status === 'pending' || o.status === 'processing' || o.status === 'shipped'
)
const completedOrders = orders.filter(o =>
o.status === 'delivered' || o.status === 'cancelled'
)Step 1: Extract
import { oneOf } from '@ontologyio/receta/predicate'
const isActiveStatus = oneOf(['pending', 'processing', 'shipped'])
const isCompletedStatus = oneOf(['delivered', 'cancelled'])Step 2: Refactor
const activeOrders = R.filter(orders, (o) => isActiveStatus(o.status))
const completedOrders = R.filter(orders, (o) => isCompletedStatus(o.status))
// Or with where():
const activeOrders2 = R.filter(orders, where({ status: isActiveStatus }))
const completedOrders2 = R.filter(orders, where({ status: isCompletedStatus }))Example 3: Multi-Field Object Filter
Before
const eligibleUsers = users.filter(u =>
u.age >= 18 &&
u.verified === true &&
u.country === 'US' &&
(u.role === 'admin' || u.role === 'moderator')
)Step 1: Break Down
- Age check:
u.age >= 18 - Verified:
u.verified === true - Country:
u.country === 'US' - Role:
u.role === 'admin' || u.role === 'moderator'
Step 2: Extract
import { where, gte, eq, oneOf } from '@ontologyio/receta/predicate'
const isEligible = where({
age: gte(18),
verified: eq(true),
country: eq('US'),
role: oneOf(['admin', 'moderator'])
})Step 3: Refactor
const eligibleUsers = R.filter(users, isEligible)Example 4: Type Checking
Before
const data: unknown[] = [/* mixed types */]
const strings = data.filter(x => typeof x === 'string')
const numbers = data.filter(x => typeof x === 'number' && !isNaN(x))
const validObjects = data.filter(x =>
typeof x === 'object' && x !== null && !Array.isArray(x)
)Step 1: Use Type Guards
import { isString, isNumber, isObject } from '@ontologyio/receta/predicate'
const strings = R.filter(data, isString) // Type: string[]
const numbers = R.filter(data, isNumber) // Type: number[]
const validObjects = R.filter(data, isObject) // Type: Record<string, unknown>[]Bonus: TypeScript automatically narrows types!
Example 5: Complex Boolean Logic
Before
const canEdit = users.filter(u =>
u.id === post.authorId ||
(u.role === 'admin' && u.verified) ||
(u.role === 'moderator' && u.reputation > 1000)
)Step 1: Break Into Pieces
import { where, eq, and, or, gt } from '@ontologyio/receta/predicate'
const isAuthor = where({ id: eq(post.authorId) })
const isVerifiedAdmin = where({ role: eq('admin'), verified: eq(true) })
const isSeniorMod = where({ role: eq('moderator'), reputation: gt(1000) })Step 2: Compose
const canEditPost = or<User>(isAuthor, isVerifiedAdmin, isSeniorMod)Step 3: Refactor
const canEdit = R.filter(users, canEditPost)Example 6: Nested Property Access
Before
const usOrders = orders.filter(o => o.customer.country === 'US')
const verifiedOrders = orders.filter(o => o.customer.verified === true)
const usVerifiedOrders = orders.filter(o =>
o.customer.country === 'US' && o.customer.verified === true
)Step 1: Extract
import { where, eq } from '@ontologyio/receta/predicate'
const hasUSCustomer = where({
customer: where({
country: eq('US')
})
})
const hasVerifiedCustomer = where({
customer: where({
verified: eq(true)
})
})
const hasUSVerifiedCustomer = where({
customer: where({
country: eq('US'),
verified: eq(true)
})
})Step 2: Refactor
const usOrders = R.filter(orders, hasUSCustomer)
const verifiedOrders = R.filter(orders, hasVerifiedCustomer)
const usVerifiedOrders = R.filter(orders, hasUSVerifiedCustomer)Example 7: Derived Values
Before
const popularUsers = users.filter(u => u.followers.length > 1000)
const recentPosts = posts.filter(p => {
const daysSince = (Date.now() - p.createdAt.getTime()) / (24 * 60 * 60 * 1000)
return daysSince <= 7
})Step 1: Use by()
import { by, gt, lte } from '@ontologyio/receta/predicate'
const isPopular = by((u: User) => u.followers.length, gt(1000))
const isRecent = by((p: Post) => {
const daysSince = (Date.now() - p.createdAt.getTime()) / (24 * 60 * 60 * 1000)
return daysSince
}, lte(7))Step 2: Refactor
const popularUsers = R.filter(users, isPopular)
const recentPosts = R.filter(posts, isRecent)Incremental Migration
You don't need to refactor everything at once!
Strategy 1: Start with Duplicates
Find the most duplicated filter logic and extract it first.
// 1. Find duplicates
// โ Repeated everywhere:
users.filter(u => u.age >= 18 && u.verified)
// 2. Extract
const isVerifiedAdult = where({ age: gte(18), verified: eq(true) })
// 3. Gradually replace call sites
R.filter(users, isVerifiedAdult)Strategy 2: Create a Predicates Module
// predicates/user.ts
export const isAdult = where({ age: gte(18) })
export const isVerified = where({ verified: eq(true) })
export const isActive = where({ status: eq('active') })
export const isStaff = where({ role: oneOf(['admin', 'moderator']) })
// Compose
export const isVerifiedAdult = and<User>(isAdult, isVerified)
export const isActiveStaff = and<User>(isActive, isStaff)// Usage
import * as UserPred from './predicates/user'
R.filter(users, UserPred.isVerifiedAdult)
R.filter(users, UserPred.isActiveStaff)Strategy 3: Coexist During Migration
Old and new can live side-by-side:
// โ
Keep existing code working
const oldFilter = users.filter(u => u.age >= 18)
// โ
New code uses predicates
const newFilter = R.filter(users, where({ age: gte(18) }))Common Pitfalls
Pitfall 1: Over-Abstraction
// โ Too granular
const isAgeGreaterThan18 = gt(18)
const hasAgeGreaterThan18 = where({ age: isAgeGreaterThan18 })
// โ
Right level
const isAdult = where({ age: gt(18) })Rule: Abstract when logic is reused, not just for the sake of it.
Pitfall 2: Forgetting Short-Circuit Order
// โ Expensive check first
and(
complexDatabaseQuery, // Slow!
simplePropertyCheck // Fast
)
// โ
Fast check first
and(
simplePropertyCheck,
complexDatabaseQuery // Only runs if first passes
)Pitfall 3: Not Using Built-Ins
// โ Reinventing the wheel
const notEmpty = (s: string) => s.length > 0
// โ
Use built-in
import { isNotEmpty } from '@ontologyio/receta/predicate'Pitfall 4: Inline Predicates Everywhere
// โ Still inline, just with predicate functions
R.filter(users, where({ age: gt(18), verified: eq(true) }))
R.filter(users, where({ age: gt(18), verified: eq(true) }))
R.filter(users, where({ age: gt(18), verified: eq(true) }))
// โ
Extract if reused
const isVerifiedAdult = where({ age: gt(18), verified: eq(true) })
R.filter(users, isVerifiedAdult)
R.filter(users, isVerifiedAdult)
R.filter(users, isVerifiedAdult)Testing After Migration
Before (Hard to Test)
// Can't test filter logic in isolation
const getActiveUsers = (users: User[]) =>
users.filter(u => u.age >= 18 && u.verified && u.status === 'active')After (Easy to Test)
import { describe, it, expect } from 'vitest'
// Test predicates independently
describe('User predicates', () => {
it('isActiveUser filters correctly', () => {
const user1 = { age: 25, verified: true, status: 'active' }
const user2 = { age: 17, verified: true, status: 'active' }
const user3 = { age: 25, verified: false, status: 'active' }
expect(isActiveUser(user1)).toBe(true)
expect(isActiveUser(user2)).toBe(false) // Too young
expect(isActiveUser(user3)).toBe(false) // Not verified
})
})
// Test composition
describe('User filtering', () => {
it('getActiveUsers uses isActiveUser predicate', () => {
const users = [/* ... */]
const result = R.filter(users, isActiveUser)
expect(result).toHaveLength(2)
})
})Checklist
Use this checklist when migrating code:
- Identify duplicated filter logic
- Extract simple comparison predicates (gt, eq, oneOf, etc.)
- Create object predicates with where() for multi-field checks
- Use type guards (isString, isNumber, etc.) for type checks
- Compose complex predicates with and/or/not
- Create a predicates module for domain logic
- Write tests for extracted predicates
- Update call sites to use new predicates
- Remove old inline filters
- Document predicate meanings
Migration Examples by Domain
E-Commerce
// Before
products.filter(p => p.price > 100 && p.inStock && p.rating >= 4.0)
// After
const qualityExpensive = where({
price: gt(100),
inStock: eq(true),
rating: gte(4.0)
})User Management
// Before
users.filter(u => (u.role === 'admin' || u.role === 'mod') && u.verified)
// After
const isVerifiedStaff = where({
role: oneOf(['admin', 'mod']),
verified: eq(true)
})API Filtering
// Before
issues.filter(i => i.state === 'open' && i.labels.includes('bug') && i.assignee !== null)
// After
const openAssignedBugs = where({
state: eq('open'),
labels: (labels: string[]) => labels.includes('bug'),
assignee: not(isNull)
})Next Steps
- API Reference - Complete function reference
- Common Patterns - Real-world examples for inspiration
- README - Documentation overview