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.