skip to content

Predicate Combinators

Assembling a filter condition or a sort order from small named pieces instead of one long boolean expression. Interviewers ask it because every codebase already has that mess.

on this pageshow

questions

5

In a job-board candidate filter, what does an 'and' combinator over two named predicates return - a boolean or another predicate?

level: juniorimportance: must knowfreq 62%

answer

  1. the result is still callable
  2. a condition built, not evaluated
  3. two predicates in, one predicate out
  4. no candidate exists at assembly time
  5. the operator joins booleans, the combinator joins functions

basics

~10 s

Another predicate. A combinator builds a condition rather than evaluating one: nothing is tested until the assembled predicate is finally applied to a candidate, and then it asks both questions and combines the answers.

solid answer

~40 s

A predicate is a function from one candidate to true or false. A predicate combinator takes predicates and hands back a predicate, so `and(hasWorkPermit, seniorEnough)` produces a new function; no candidate exists at the point that line runs. The confusion comes from the name: the boolean operator combines two `true`/`false` values, while the combinator combines two functions that will later produce such values. Because the result is itself a value, you can name it, store it, pass it to whatever runs the filter, and apply it to a second list without rewriting anything. Evaluation happens once per candidate, at the moment the assembled condition is applied.

code

pseudocode · 8 lines
pseudocode
function and(left, right)
  return function(candidate)
    return left(candidate) and right(candidate)

employable = and(hasWorkPermit, seniorEnough)

// nothing has been tested yet; now it is
shortlist = filter(applicants, employable)

go deeper

for a junior

Remember the shape: predicates in, predicate out. Be able to say that no candidate is examined at the moment the filter is assembled, and that the parts run later, once per candidate.

for a middle

Explain the two moments - assembly and application - and why having the condition as a value lets you store it, pass it, and apply it to a second list without rewriting the logic.

for a senior

Show where you drew the line in a real codebase: which conditions earned named predicates, which stayed inline, and how you kept the vocabulary from drifting as the filter grew.

for a principal

Frame it as a cost: an extra naming layer bought testability and run-time assembly. Say when that trade is not worth making for a team, and what convention keeps the predicate vocabulary honest.

## A predicate, and then a combinator A **predicate** is a function that takes one value and answers a yes/no question about it: `hasWorkPermit(candidate)`, `seniorEnough(candidate)`. A **predicate combinator** is a function whose arguments are predicates and whose result is another predicate. When a job board's filter is written as `and(hasWorkPermit, seniorEnough)`, that expression does not look at a candidate - there is no candidate in scope when it runs. It yields a new function which, once it is handed a candidate, asks both questions and combines the two answers. That single distinction is the whole of the beginner version of this subject, and the mistake it guards against is reading `and` as the boolean operator it borrowed its name from. The operator combines two **booleans**. The combinator combines two **functions that produce booleans**. ## The two shapes side by side | | Inline boolean expression | Combinator | |---|---|---| | What you write | `candidate.permit and candidate.years >= 3` | `and(hasWorkPermit, seniorEnough)` | | What it evaluates to | `true` or `false` | a predicate | | When the test runs | immediately, for the one candidate in scope | later, once per candidate it is applied to | | What you can store | only the resulting boolean | the condition itself | | Are the parts named | only if you extract a variable or a function | each part is already a named value | ## Why "returns a predicate" is the property that matters - **The condition becomes a value.** It can be returned from a function, put in a list, passed to whatever walks the candidate list, and applied to a second list later. - **Each part is independently testable.** `seniorEnough` can be exercised on its own; a clause buried inside a long boolean expression cannot be, except through the whole expression. - **The pieces are reusable across filters.** The same `hasWorkPermit` appears in the search filter, in the alerting rule and in the export, and it means the same thing in all three because it is one definition. - **Assembly can be data-driven.** If the parts are values, the set of parts can be chosen at run time rather than written out as a branch per combination. - **Reading order matches intent.** `and(hasWorkPermit, seniorEnough)` says what is required; a nested chain of field comparisons says how it is checked. ## Assembly time and test time These are two different moments, and keeping them apart is what makes the rest of this material make sense: 1. **Assembly.** The combinators run. `and`, `or` and `not` are applied to predicates and produce one predicate. Zero candidates are examined. This usually happens once. 2. **Application.** The assembled predicate is applied to a candidate. Now the parts run, their booleans are combined, and one answer comes out. 3. **Repetition.** The same assembled predicate is applied to the next candidate, and the next. The assembly step is not repeated. A useful sanity check when reading unfamiliar code: ask what the expression would do if there were no candidates at all. If the answer is "nothing, it just produces a condition", it is a combinator. If the answer is "it would not compile, there is no value to test", it is an ordinary boolean expression. ## The three basic combinators The usual starting set is small, and every larger one is built from it: - **Conjunction** - a predicate that holds when both parts hold. - **Disjunction** - a predicate that holds when at least one part holds. - **Negation** - a predicate that holds exactly when its part does not. Most codebases also grow list forms - one that requires every predicate in a collection to hold, and one that requires at least one - because the number of criteria is usually discovered at run time rather than known when the code is written. ## What the style costs It is not free, and an interviewer will respect a candidate who says so: - **A layer of indirection.** Reading the filter now means looking up what each named predicate does, instead of seeing the comparison inline. - **Overkill for a one-off.** A single two-clause condition used in exactly one place is clearer written out. - **A vocabulary to maintain.** Named predicates only pay off if names are accurate; a `seniorEnough` whose definition quietly drifts is worse than the comparison it replaced. The trade is the usual composition trade: you accept one more level of naming in exchange for parts that can be tested, reused and assembled by something other than a programmer typing out every combination.

  • What must the two predicates agree about for a conjunction combinator to make sense?
    They must ask about the same kind of subject. Both take a candidate, so the combined predicate can hand the one candidate it receives to each part. Combining a predicate over candidates with a predicate over job postings has nothing to pass to both, which a statically checked language rejects outright and a dynamically checked one discovers at the first application.
  • If the combinator returned a boolean instead of a predicate, what would a caller have to do to filter a second list?
    Rewrite the condition at the second call site, because the only thing it received was one answer about one candidate. Returning a predicate means the condition itself is the reusable artifact: the second list is filtered by passing the same value somewhere else, with no duplicated logic to drift apart.

Combining two recipe cards gives you a third recipe card, not a cooked meal. The cooking only happens when someone finally takes the card into the kitchen with actual ingredients.

saying these in an interview costs you the question

  • Says the combinator answers true or false straight away
  • Thinks the named predicates run when the filter is assembled
  • Cannot say what argument the combined predicate expects
  • Treats named predicates as purely cosmetic sugar for one expression
  • Assumes building conditions this way needs a class hierarchy
open as a page

How do you build a result ordering like 'best match first, then earliest application' from small comparators rather than one comparison function?

level: middleimportance: must knowfreq 58%

basics

~20 s

Give each comparator one key and one direction, then chain them: the next comparator is consulted only when the previous one reports a tie. Each returns a three-way verdict - before, equal, after - and 'equal' is the signal that hands control to the tiebreaker.

open as a page

A job board must list the candidates its filter rejected; why is negating a two-part 'and' filter not simply negating both parts?

level: middleimportance: should knowfreq 54%

basics

~20 s

Negation flips the connective as well as the parts. The complement of 'permit and three years' is 'no permit OR under three years', not 'no permit AND under three years' - the second selects only the candidates who fail both tests.

open as a page

A candidate filter combines a cheap stored-field test with an expensive computed-score test using 'and' - what does the order you combine them in decide?

level: seniorimportance: should knowfreq 44%

basics

~20 s

It decides how often the expensive test runs. A conjunction is settled by the first part that fails, so a cheap, highly rejecting test placed first keeps the score computation off most candidates - the accepted set is the same either way, the work is not.

open as a page

A job board lets a recruiter tick any subset of filter criteria; how do you assemble the matching condition at run time?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

Map each ticked criterion to its named predicate, collect the chosen ones into a list, and fold the list into a single condition with the conjunction combinator. Ticking nothing folds to a condition that accepts every candidate.

open as a page