Vidal Vasconcelos
← Back to home

Predicate in the real world: naming the rules buried in your ifs with fp-ts

Somewhere in every checkout there is a condition that began as a single line and, over the years, quietly grew. A clause arrived when free shipping launched, another when fraud grew cunning, a third when a new region came online, and one ordinary afternoon you look up to find a wall of boolean expressions that no one dares disturb. The booleans were never the trouble. The trouble is that each line is trying to tell you something simple and human about an order, and by pressing them all into a single breath we have muffled every voice at once.

But lean in close to any one of those lines and you find something almost tender hiding inside it: a claim about the order, and nothing more. Say one out loud. “The order ships free.” There is the thing you are speaking of, the order, and then the claim you make about it, ships free, the part that comes out true or false. Grammar has a name for it, the predicate, and it is no accident that fp-ts reaches for the very same word: a Predicate is simply a claim that resolves to true or false. You have written thousands of them without ever pausing to name one. Every array filter callback is such a sentence, and the test you hand to filter to keep orders above a minimum is a claim about an order, quietly insisting it clears the minimum. Give a sentence a name and you may join it to others, and that is all we will do here: lift the sentences out of this overgrown condition, one at a time.

Modeling the domain

We are back in the same little shop we have been tending across these posts. A cart holds the Product values we first met in the contramap post, a running total, and the person standing at the counter hoping to pay. The customer is a small world of its own, so we give it a module of its own:

// src/customer.ts

/**
 * the customer trying to pay, and the facts checkout cares about
 */
export interface Customer {
  readonly country: string
  readonly verified: boolean
  readonly banned: boolean
}

The basket of products the customer is carrying is its own little world too, so we give it a name of its own and a module to match:

// src/cart.ts
import { Product } from './product'

/**
 * the products the customer is carrying to the counter
 */
export type Cart = ReadonlyArray<Product>

And the checkout gathers the pieces into one place:

// src/checkout.ts
import { Cart } from './cart'
import { Customer } from './customer'

/**
 * everything one checkout attempt puts on the table
 */
export interface Checkout {
  readonly cart: Cart
  readonly total: number
  readonly customer: Customer
}

The giant if

Gather that condition into a function of its own, and here is how it looks today:

function canCheckout(checkout: Checkout): boolean {
  return (
    checkout.cart.length > 0 &&
    checkout.total >= 50 &&
    !checkout.cart.some(isProductUnavailable) &&
    (checkout.customer.country === 'US' || checkout.customer.country === 'CA') &&
    checkout.customer.verified &&
    !checkout.customer.banned
  )
}

It works. It has very likely worked, faithfully, for years. And yet read it back slowly and notice how much you must reassemble in your own head: every line carries a name (the cart is not empty, we ship to where they live) that was never written down. No single rule can be examined on its own, and when the shipping calculator wants that same “do we ship here?” question, the line is copied across and, in time, the two quietly disagree. None of the meaning is lost, exactly; it is only trapped, all of it, on a single line. So let us give each rule its name back.

A quick Predicate refresher

Underneath the grand-sounding name, a Predicate in fp-ts is the most modest thing imaginable: a function from a value to a boolean.

type Predicate<A> = (a: A) => boolean

That is our grammar sentence written in TypeScript: hand it a value and it tells you true or false. The filter callback from a moment ago already has this exact shape, so naming it is the only thing standing between a throwaway lambda and a rule you can hold onto and use again.

A single function, on its own, would hardly deserve a module. What earns it one is that the Predicate module also hands you the small words that join sentences together. First the connectives, not, and, and or, the very words you would say aloud:

const not: <A>(predicate: Predicate<A>) => Predicate<A>
const and: <A>(second: Predicate<A>) => (first: Predicate<A>) => Predicate<A>
const or: <A>(second: Predicate<A>) => (first: Predicate<A>) => Predicate<A>

Then a contramap, for turning a rule to face the field it speaks about, the same travelling adapter we packed in the contramap post. And two Monoids, getMonoidAll and getMonoidAny, for folding an entire list of rules down into one, the very fold we relied on in the Monoids post. We will reach for each of them in turn as we take the condition apart.

Naming the clauses

The plainest step turns out to do the most work: give every line a name, and set it down in the module that owns that corner of the domain. Begin with the two rules that read straight off the cart:

// src/checkout.ts
import * as P from 'fp-ts/Predicate'

/**
 * the cart has at least one item
 */
const hasItems: P.Predicate<Checkout> = (checkout) => checkout.cart.length > 0

/**
 * the order clears the $50 minimum
 */
const meetsMinimum: P.Predicate<Checkout> = (checkout) => checkout.total >= 50

Already the fog lifts a little: each rule has a name you can speak and a home where you can test it alone. But look at what keeps repeating. Every predicate takes in a whole checkout, reaches inside for a single field, then asks a question that has nothing to do with checkouts at all. “Is this at least 50?” is, at heart, a question about a number, and that seam is exactly where contramap finds its purpose.

Pointing each rule at its field

So write the question where it truly belongs, about a number, and write it only once:

// src/number.ts
import * as P from 'fp-ts/Predicate'

/**
 * is the number at least the given floor?
 */
export const atLeast = (min: number): P.Predicate<number> => (n) => n >= min

Then turn it to face a checkout by naming the field it should read. Here is that travelling adapter from the contramap post once more: contramap alters neither the rule nor the checkout, it simply lets a question about numbers catch sight of the one number it needs.

// src/checkout.ts
import { pipe } from 'fp-ts/function'
import * as P from 'fp-ts/Predicate'
import { atLeast } from './number'

/**
 * the order's total clears the $50 minimum
 */
const meetsMinimum: P.Predicate<Checkout> = pipe(
  atLeast(50),
  P.contramap((checkout: Checkout) => checkout.total),
)

Now atLeast will serve any numeric field anywhere, and the rule reads as plainly as its own name. The cart and the customer are little worlds of their own, so their rules belong to them rather than to the checkout. Each one gets a module that names its claims and composes the single question the checkout will actually ask. The cart first, borrowing the predicates fp-ts already provides for arrays:

// src/cart.ts
import { pipe } from 'fp-ts/function'
import * as P from 'fp-ts/Predicate'
import * as RA from 'fp-ts/ReadonlyArray'
import { isProductUnavailable } from './product'

/**
 * the cart has at least one item
 */
export const hasItems: P.Predicate<Cart> = RA.isNonEmpty

/**
 * no item in the cart is unavailable
 */
export const hasOnlyAvailableItems: P.Predicate<Cart> = pipe(
  RA.some(isProductUnavailable),
  P.not,
)

/**
 * a cart we are ready to charge: stocked, every item available
 */
export const isReady: P.Predicate<Cart> = pipe(
  hasItems,
  P.and(hasOnlyAvailableItems),
)

The “no item unavailable” rule leans on isProductUnavailable from the contramap post: we ask whether any item matches it and turn that answer over with not. Then and joins the two into isReady, the one cart-shaped sentence the checkout will need.

The customer's rules belong to the customer in the same way, in the language of a customer and nothing else. Each small claim earns its own name first, and then the module composes them into the single question the checkout will actually ask: may we sell to this customer at all?

// src/customer.ts
import { pipe } from 'fp-ts/function'
import * as P from 'fp-ts/Predicate'

/**
 * the customer has confirmed their account
 */
export const isVerified: P.Predicate<Customer> = (customer) => customer.verified

/**
 * the customer is blocked from buying
 */
export const isBanned: P.Predicate<Customer> = (customer) => customer.banned

/**
 * the customer is in a given country
 */
const isCountry = (code: string): P.Predicate<Customer> => (customer) => customer.country === code

/**
 * we ship where we operate: the US or Canada
 */
export const shipsToServedCountry: P.Predicate<Customer> = pipe(
  isCountry('US'),
  P.or(isCountry('CA')),
)

/**
 * a customer we will sell to: verified, not banned, in a country we serve
 */
export const canPurchase: P.Predicate<Customer> = pipe(
  isVerified,
  P.and(P.not(isBanned)),
  P.and(shipsToServedCountry),
)

Every connective the module hands us is now a word in a sentence. or recovers the lone or of the giant condition as “we ship to the US or to Canada.” not turns “is banned” gently into “is not banned,” with no negation sign left loose in the middle of a condition. and joins the three together into canPurchase, a single claim about a customer that reads exactly like the policy it stands for.

This is the quiet dividend of giving each rule a home. isVerified and isBanned belong to the customer rather than hiding inside the checkout, so the rest of the system can speak them too: a screen that lists only verified customers filters with the same isVerified, a signup that turns away a banned one validates with the very same rule. And the composed canPurchase becomes the one word any sales surface, online checkout, in-store kiosk, phone order, can ask in unison to mean the same thing.

Back in the checkout module, those domain rules must speak about a whole checkout, not about a cart or a customer standing alone. contramap reaches into the checkout for the field each one cares about before the rule asks its question, and one lift per module covers every claim that module bundles:

// src/checkout.ts
import { pipe } from 'fp-ts/function'
import * as P from 'fp-ts/Predicate'
import * as Cart from './cart'
import * as Customer from './customer'

/**
 * the cart on this checkout is ready to charge
 */
const cartIsReady: P.Predicate<Checkout> = pipe(
  Cart.isReady,
  P.contramap((checkout: Checkout) => checkout.cart),
)

/**
 * the customer on this checkout is one we will sell to
 */
const customerCanPurchase: P.Predicate<Checkout> = pipe(
  Customer.canPurchase,
  P.contramap((checkout: Checkout) => checkout.customer),
)

Folding the rules into one

Three named sentences, each owned by the module it speaks about, and a checkout is allowed through only when all of them hold at once. “All of them, together” is itself a Monoid: getMonoidAll joins two predicates by insisting on both, and its empty is the predicate that is always true, the quiet neutral element of that joining. concatAll then folds the whole list down to a single rule, the same fold that merged accounts in the Monoids post:

// src/checkout.ts
import * as M from 'fp-ts/Monoid'
import * as P from 'fp-ts/Predicate'

/**
 * combine checkout rules with and: every one must hold, and an empty
 * list folds to the always-true predicate, so nothing is rejected by accident
 */
const everyRule: M.Monoid<P.Predicate<Checkout>> = P.getMonoidAll<Checkout>()

/**
 * a checkout may proceed only when every rule holds
 */
export const canCheckout: P.Predicate<Checkout> = M.concatAll(everyRule)([
  cartIsReady,
  meetsMinimum,
  customerCanPurchase,
])

That empty is what makes the awkward corner case simply dissolve, just as it did for the empty account and the empty cart of discounts. Fold an empty list of rules and you meet neither a crash nor a clumsy default but the always-true predicate: with no rules to answer to, everything is welcome, because a clause that is always true asks nothing of anyone when the whole point is that every clause must hold.

Its mirror image is getMonoidAny, which holds the moment either side does and whose empty is always false. Reach for it when a single match is enough on its own, as when you wish to set an order aside for a human being the instant one risk signal stirs:

/**
 * combine risk signals with or: any one is enough, and an empty list
 * folds to the always-false predicate, so nothing is flagged by accident
 */
const anySignal: M.Monoid<P.Predicate<Checkout>> = P.getMonoidAny<Checkout>()

/**
 * any one risk signal is enough to flag for review
 */
export const needsReview: P.Predicate<Checkout> = M.concatAll(anySignal)([
  isHighValue,
  shipsToRiskyRegion,
  usesFlaggedCoupon,
])

The same fold, with the opposite resting state: an empty list of signals folds to always-false, nothing amiss, nothing to review.

The call site

The wall of booleans we began with now comes down to a single call that says plainly what it means:

// src/checkout-handler.ts
import * as Checkout from './checkout'

if (Checkout.canCheckout(checkout)) {
  // take payment
}

Every rule behind that call now has a name and a home, and each can be questioned on its own. When the verdict comes back against a customer, you no longer squint into a long expression; you simply go and read the one named rule that said no. Hand it a real checkout and it answers you in a single word:

import * as assert from 'node:assert'
import * as Checkout from './checkout'
import { Product } from './product'

const widget: Product = {
  id: 'p1',
  name: 'Widget',
  category: { id: 'c1', name: 'Tools', disabled: false },
}

/**
 * a verified US shopper with an $80 cart of available items
 */
const checkout: Checkout.Checkout = {
  cart: [widget],
  total: 80,
  customer: { country: 'US', verified: true, banned: false },
}

// the same shopper, but the cart falls short of the $50 minimum
const underMinimum: Checkout.Checkout = { ...checkout, total: 20 }

assert.strictEqual(Checkout.canCheckout(checkout), true)
assert.strictEqual(Checkout.canCheckout(underMinimum), false)

A few weeks later

Then, a few weeks on, the risk team arrives with a new rule: an order over $500 may pass only if the customer has a verified ID. The customer grows a single field, and a one-line predicate gives it a name:

// src/customer.ts
export interface Customer {
  // ...the existing fields...
  readonly idVerified: boolean
}

/**
 * the customer has a verified government ID on file
 */
const hasVerifiedId: P.Predicate<Customer> = (customer) => customer.idVerified

/**
 * the customer has completed full verification; today that means a verified
 * ID, tomorrow it may mean more
 */
export const isFullyVerified: P.Predicate<Customer> = hasVerifiedId

Most payments platforms split customers into verification tiers: small orders pass on basic verification, larger ones require the customer to be fully verified. The rule reads as an “if, then”: if the order is high in value, then the customer must be fully verified. Logic says the same thing with words we already hold, “not high value, or else fully verified,” and the checkout module asks only for that single sentence; what full verification actually inspects is a detail it lets the customer keep:

// src/checkout.ts

/**
 * orders over $500 are the high-value ones
 */
const isHighValue: P.Predicate<Checkout> = pipe(
  atLeast(500),
  P.contramap((checkout: Checkout) => checkout.total),
)

/**
 * lift the full-verification claim from the customer onto a checkout
 */
const customerIsFullyVerified: P.Predicate<Checkout> = pipe(
  Customer.isFullyVerified,
  P.contramap((checkout: Checkout) => checkout.customer),
)

/**
 * high-value orders need full verification: not high value, or fully verified
 */
const meetsVerificationRequirement: P.Predicate<Checkout> = pipe(
  P.not(isHighValue),
  P.or(customerIsFullyVerified),
)

Adding it to the verdict costs one more line in the list. Nothing else has to move:

// src/checkout.ts

/**
 * the same verdict, now with the high-value ID rule folded in
 */
export const canCheckout: P.Predicate<Checkout> = M.concatAll(everyRule)([
  cartIsReady,
  meetsMinimum,
  customerCanPurchase,
  meetsVerificationRequirement,
])

And the list itself quietly hands you one more thing. Each line is now a rich domain claim, owned by the module that knows its language: the cart speaks of its items, the customer says whether we will sell to them, and the checkout composes those voices with the only rules that are truly its own, the minimum and the cross-cutting high-value clause. The verdict reads as the policy it stands for, and a new requirement lands where it belongs, a customer concern in the customer module, a cart concern in the cart, the checkout asking for a sentence and not a paragraph.

Conclusion

A predicate, in the end, is just a sentence about a value: the small true-or-false test we usually scribble inline and write again the next time we need it, never stopping to name it. A giant condition is a paragraph of those throwaways crushed onto one line until the meaning is squeezed out. Naming each one lets the meaning back in, and giving it a home in the module that owns it turns a line you would have rewritten everywhere into a word the whole system can speak, test on its own, and reuse in a filter or a validation far from checkout: not, and, and or are the connectives you would speak aloud, contramap turns each rule to face the field it is really about, and a Monoid folds them all into one verdict that reads like the policy it stands for. We ranked products in the sorting post, adapted predicates across types in the contramap post, merged whole entities in the Monoids post, and capped values in the Bounded post. Here we have done something quieter: we gave a tangle of booleans its voice back. That “implies” near the end is a small door into what comes next, for add it to the connectives and these rules become a BooleanAlgebra. The old refrain holds: define the small pieces with care, and let composition do the heavy lifting.