Predicate
Overview
Predicates are reusable, type-safe functions for filtering, validation, and type narrowing. Build complex conditions from simple building blocks like gt(18), eq('active'), and where({ age: gt(18), status: eq('active') }).
The Problem: Scattered Filtering Logic
The Hidden Cost of Inline Filters
// ❌ Traditional approach - logic scattered everywhere
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)
// What's duplicated? The "adult active user" logic (age >= 18 && active === true)
// What if requirements change? Update 3+ places
// What if logic is complex? Copy-paste growsProblems with inline filters:
- Duplication: Same logic repeated across codebase
- Not reusable: Can't extract or compose conditions
- Hard to test: Logic embedded in larger functions
- Not discoverable: No central place to find "what filters exist?"
- Type unsafe: No type narrowing for mixed-type arrays
How Production Applications Handle This
Prisma ORM (Database Queries)
// Prisma's where clause - composable predicates for databases
const users = await prisma.user.findMany({
where: {
age: { gte: 18 },
status: { in: ['active', 'premium'] },
email: { contains: '@gmail.com' }
}
})MongoDB Queries
// MongoDB - query operators are predicates
db.users.find({
age: { $gt: 18 },
status: { $in: ['active', 'premium'] },
'profile.verified': true
})TypeORM Repository
// TypeORM - composable conditions
const users = await userRepository.find({
where: {
age: MoreThan(18),
status: In(['active', 'premium']),
verified: true
}
})What they all share:
- ✅ Composable conditions (combine small pieces)
- ✅ Named operators (
gte,in,contains) - ✅ Type-safe filtering
- ✅ Reusable patterns
The Solution: Predicate Functions
import * as R from 'remeda'
import { where, gte, eq, oneOf } from '@ontologyio/receta/predicate'
// ✅ Define reusable predicates
const isAdult = gte(18)
const isActive = eq(true)
const isStaff = oneOf(['admin', 'moderator'])
// ✅ Compose into higher-level predicates
const isActiveAdult = where({
age: isAdult,
active: isActive
})
const isActiveStaff = where({
age: isAdult,
active: isActive,
role: isStaff
})
// ✅ Use anywhere
R.filter(users, isActiveAdult)
R.filter(users, isActiveStaff)
R.find(users, isActiveStaff)Benefits:
- 🔄 Reusable: Define once, use everywhere
- 🧩 Composable: Build complex from simple
- 🔒 Type-safe: TypeScript knows what you're filtering
- 🧪 Testable: Test predicates in isolation
- 📖 Discoverable: All filters in one module
- 🎯 Explicit: Clear intent (
gte(18)vs>= 18)
Why Predicates Over Inline Filters?
Problem 1: Inline Filters Don't Compose
// ❌ Can't extract or reuse
users.filter(u => u.age >= 18 && u.verified)
products.filter(p => p.price >= 100 && p.inStock)
orders.filter(o => o.amount >= 1000 && o.status === 'pending')
// Each has similar structure (threshold check + boolean)
// But you can't extract a "greaterThan" helper easily// ✅ With predicates - composition is natural
import { gte, eq, where } from '@ontologyio/receta/predicate'
const isAdult = gte(18)
const isExpensive = gte(100)
const isLargeOrder = gte(1000)
R.filter(users, where({ age: isAdult, verified: Boolean }))
R.filter(products, where({ price: isExpensive, inStock: Boolean }))
R.filter(orders, where({ amount: isLargeOrder, status: eq('pending') }))Problem 2: Inline Filters Lose Type Information
// ❌ TypeScript can't narrow this inline check
const mixed: unknown[] = ['hello', 42, NaN, 'world']
const numbers = mixed.filter(x => typeof x === 'number' && !isNaN(x))
// Type of numbers: unknown[] ❌ (should be number[])// ✅ Type guards narrow types automatically
import { isNumber } from '@ontologyio/receta/predicate'
const numbers = R.filter(mixed, isNumber)
// Type of numbers: number[] ✅Problem 3: Inline Filters Don't Work in Pipelines
// ❌ Awkward in pipe - need arrow functions
R.pipe(
users,
R.filter(u => u.age >= 18),
R.filter(u => u.active),
R.filter(u => u.role === 'admin')
)// ✅ Clean pipeline with predicates
R.pipe(
users,
R.filter(prop('age', gte(18))),
R.filter(prop('active', Boolean)),
R.filter(prop('role', eq('admin')))
)
// Or combine into one:
R.pipe(
users,
R.filter(where({
age: gte(18),
active: Boolean,
role: eq('admin')
}))
)Real-World Use Cases
1. API Filtering (GitHub-style)
interface Issue {
id: number
state: 'open' | 'closed'
labels: string[]
assignee: string | null
}
// Filter: Open bugs assigned to someone
const openAssignedBugs = where({
state: eq('open'),
labels: (labels: string[]) => labels.includes('bug'),
assignee: not(isNull)
})
R.filter(issues, openAssignedBugs)2. E-Commerce Product Search
// Price range + rating + availability
const affordableQuality = where({
price: between(50, 200),
rating: gte(4.0),
inStock: Boolean
})
R.filter(products, affordableQuality)3. User Permissions
// Complex permission logic
const canAccessAdmin = or<User>(
prop('role', eq('admin')),
and<User>(
prop('role', eq('moderator')),
prop('verified', Boolean),
by((u: User) => u.joinedDays, gt(30))
)
)
if (canAccessAdmin(currentUser)) {
// Grant access
}4. Data Validation
// Validate form data
const isValidEmail = matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
const isStrongPassword = and(
by((s: string) => s.length, gte(8)),
matches(/[A-Z]/),
matches(/[0-9]/)
)
const errors: string[] = []
if (!isValidEmail(formData.email)) errors.push('Invalid email')
if (!isStrongPassword(formData.password)) errors.push('Weak password')5. Database-Like Queries
// SQL: SELECT * FROM orders WHERE status IN ('pending', 'shipped') AND amount > 100
const activeHighValueOrders = where({
status: oneOf(['pending', 'shipped']),
amount: gt(100)
})
R.filter(orders, activeHighValueOrders)Mental Model: SQL WHERE Clauses for Arrays
Think of predicates as SQL WHERE clauses for in-memory data:
-- SQL
SELECT * FROM users WHERE age >= 18 AND status = 'active'// Receta Predicate (same structure)
R.filter(users, where({
age: gte(18),
status: eq('active')
}))| SQL Operator | Predicate Function |
|---|---|
>, >=, <, <= | gt(), gte(), lt(), lte() |
=, != | eq(), neq() |
BETWEEN | between(min, max) |
IN (...) | oneOf([...]) |
LIKE '%text%' | includes('text') |
IS NULL | isNull, isNullish |
IS NOT NULL | isDefined |
AND | and(...) |
OR | or(...) |
NOT | not(...) |
When to Use Predicates
✅ Use Predicates When:
- Filtering collections - Arrays, sets, maps
- Complex conditions - More than 2 checks combined
- Reusable logic - Same filter used multiple times
- Type narrowing - Working with
unknownor union types - Validation - Checking if data meets criteria
- Permissions - User access control logic
- Search/Query - Building search interfaces
- Testing - Need to verify conditions in isolation
❌ Don't Use For:
- One-off simple checks -
if (x > 5)is fine for unique cases - Non-boolean logic - Predicates return true/false only
- Mutations - Predicates should be pure (no side effects)
- Complex transformations - Use
maporflatMapinstead
Predicate Categories
1. Comparison (gt, lt, eq, between)
For numeric, string, and value comparisons.
gt(18) // age > 18
between(10, 50) // 10 <= price <= 50
eq('active') // status === 'active'2. Combinators (and, or, not)
For logical composition.
and(gt(18), lt(65)) // 18 < age < 65
or(eq('admin'), eq('owner')) // role === 'admin' OR 'owner'
not(isEmpty) // not empty3. Builders (where, oneOf, prop)
For structured object filtering.
where({ age: gt(18), active: Boolean }) // Multi-field check
oneOf(['red', 'blue', 'green']) // Value in list
R.filter(users, prop('status', eq('active'))) // Single property check4. Type Guards (isString, isNumber, isDefined)
For type narrowing and runtime checks.
isString // typeof x === 'string'
isNumber // typeof x === 'number' && !isNaN(x)
isDefined // x != nullArchitecture: Pure Functions
// A predicate is just: (value: T) => boolean
type Predicate<T> = (value: T) => boolean
// Simple predicates
const isEven: Predicate<number> = (n) => n % 2 === 0
const isPositive: Predicate<number> = (n) => n > 0
// Predicate factories (return predicates)
const gt = <T extends number | bigint>(threshold: T): Predicate<T> => (value) => value > threshold
const eq = <T>(expected: T): Predicate<T> => (value) => value === expected
// Higher-order combinators (predicates → predicate)
const and = <T>(...preds: Predicate<T>[]): Predicate<T> =>
(value) => preds.every(p => p(value))Key properties:
- ✅ Pure: No side effects
- ✅ Composable: Combine freely
- ✅ Type-safe: TypeScript checks everything
- ✅ Lazy: Only evaluated when called
- ✅ Cacheable: Same input = same output
Integration with Remeda
Predicates work seamlessly with Remeda's collection functions:
import * as R from 'remeda'
import { where, gt, gte, lt, eq, prop } from '@ontologyio/receta/predicate'
const users: User[] = [/* ... */]
// filter - Keep matching items
R.filter(users, where({ age: gt(18) }))
// find - First match
R.find(users, where({ role: eq('admin') }))
// partition - Split by predicate
const [active, inactive] = R.partition(users, prop('active', Boolean))
// takeWhile - Take until predicate fails
R.takeWhile(numbers, lt(10))
// dropWhile - Drop until predicate fails
R.dropWhile(numbers, lt(10))
// Remeda has no some or every, so use the array methods
users.some(prop('verified', Boolean))
users.every(where({ age: gte(18) }))Performance Considerations
Short-Circuit Evaluation
// ✅ and() short-circuits on first false
and(
expensiveCheck, // If this fails...
anotherCheck // ...this never runs
)
// ✅ or() short-circuits on first true
or(
quickCheck, // If this succeeds...
expensiveCheck // ...this never runs
)Predicate Factories Are Fast
// Creating predicates is cheap (no computation)
const isAdult = gt(18) // Just returns a function
const isActive = eq(true) // Just returns a function
// Only when you CALL them does work happen
isAdult(25) // Now we compute: 25 > 18Memoization for Expensive Predicates
import { memoize } from '@ontologyio/receta/memo'
// If predicate does expensive work, memoize it
const expensivePredicate = memoize((user: User) => {
// Complex calculation
return user.score > computeThreshold(user.history)
})Common Patterns
Pattern 1: Define Domain Predicates
// predicates/user.ts
export const isAdult = where({ age: gte(18) })
export const isVerified = where({ verified: Boolean })
export const isStaff = where({ role: oneOf(['admin', 'moderator']) })
export const isActiveUser = where({ status: eq('active'), verified: Boolean })Pattern 2: Compose for Permissions
// permissions.ts
const canEditPost = (user: User, post: Post) =>
or<User>(
where({ id: eq(post.authorId) }), // Author can edit
and<User>(isStaff, isVerified) // Verified staff can edit
)(user)Pattern 3: Validation Schemas
const isValidUser = where({
email: matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
age: between(13, 120),
username: by((s: string) => s.length, between(3, 20))
})
if (!isValidUser(userData)) {
throw new Error('Invalid user data')
}What's Next?
Now that you understand the "why" of predicates, dive into specific APIs:
- Comparison Predicates -
gt,lt,eq,between, string matching - Combinators -
and,or,not,xorfor logic - Builders -
where,oneOf,propfor structured queries - Type Guards -
isString,isNumber, type narrowing - Common Patterns - Real-world recipes and examples
- Migration Guide - From inline filters to predicates
- API Reference - Complete function listing
Or jump straight to Common Patterns for copy-paste recipes!