Vidal Vasconcelos
← Back to home

BooleanAlgebra in the real world: encoding compliance rules with fp-ts

Lending is a business of conditions. Before a single dollar moves, an application must clear a rulebook that regulators and risk officers have been adding to for decades, and almost every line of it has the same shape: if this, then that. If the loan is large, the applicant's income must be documented. If the borrower is applying from abroad, their identity must be checked in full. Each rule is easy to nod along to. Each one is also, once it reaches code, easy to get subtly and expensively wrong, because a computer does not speak if, then; it speaks in logic operators, and the narrow gap between the sentence and its translation is exactly where the bugs, and the audit findings, live.

We wrote our first such translation at the very end of the predicate post, at a checkout counter rather than a loan desk. The compliance team had a rule, a high-value order must come from a fully verified customer, and to make it sit among the others we wrote it sideways: not high value, or fully verified. We promised then that the word we were stepping around, implies, was a door into something larger. This is that door. Behind it is an fp-ts BooleanAlgebra, and it pays for itself in ways a pile of logic operators never can: it gives the conditional rule a name you cannot misread, it lets the whole rulebook fold into one value the entire system can share, and it brings the laws that let you transcribe a regulation in the words the lawyer used and still prove the code means what the policy means. No rulebook needs all three more than a lender's. To see why, we have to first get a single rule wrong.

The rule, and the trap

We move from the till to the loan desk. An applicant carries the few facts the rulebook keeps asking after:

// src/applicant.ts

export interface Applicant {
  readonly country: string
  readonly idVerified: boolean
  readonly incomeVerified: boolean
}

And an application gathers the person asking, the amount they want, and what they want it for:

// src/loan.ts
import * as Applicant from './applicant'

/**
 * a single request for credit
 */
export interface LoanApplication {
  readonly applicant: Applicant.Applicant
  readonly amount: number
  readonly purpose: string
}

Our first rule reads two facts. The first is about the applicant, whether their income is documented, so it lives on the applicant:

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

/**
 * the applicant has income documents on file
 */
export const incomeIsVerified: P.Predicate<Applicant> = (applicant) =>
  applicant.incomeVerified

The second is about the loan's amount. It reads the application directly, with the travelling adapter from the contramap post pointing it at the one field it cares about:

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

/**
 * loans of $50,000 or more are the large ones
 */
const isLargeLoan: P.Predicate<LoanApplication> = pipe(
  atLeast(50_000),
  P.contramap((loan: LoanApplication) => loan.amount),
)

And the applicant's income claim is lifted onto the application the same way, so both rules now speak about a whole application:

// src/loan.ts

/**
 * the applicant on this loan has income documents on file
 */
const applicantIncomeVerified: P.Predicate<LoanApplication> = pipe(
  Applicant.incomeIsVerified,
  P.contramap((loan: LoanApplication) => loan.applicant),
)

Now the rule itself: a large loan requires verified income. We want a predicate that comes back true when an application honours it. The obvious first move is to read both facts and join them with the connective that means “both”:

// src/loan.ts

// the obvious first attempt: a large loan AND verified income
const meetsIncomeRule: P.Predicate<LoanApplication> = (loan) =>
  isLargeLoan(loan) && applicantIncomeVerified(loan)

It looks right. Then a perfectly ordinary $5,000 loan, the kind this rule was never meant for, walks in:

import * as assert from 'node:assert'

const modestLoan: LoanApplication = {
  amount: 5_000,
  purpose: 'home improvement',
  applicant: { country: 'US', idVerified: false, incomeVerified: false },
}

const modestVerdict = meetsIncomeRule(modestLoan)

// the rule was meant for large loans, yet it just turned away a small one
assert.strictEqual(modestVerdict, false)

The applicant asked for a modest sum this rule was never concerned with, and we declined them. Written as large and verified, the rule has quietly stopped saying “large loans need income” and started saying every loan must be large. The and swallowed the condition, and a whole class of perfectly good borrowers with it. This is not an exotic bug; it is the single most common mistake in any rulebook, confusing if A then B with A and B, and it costs real customers.

The fix every developer eventually reaches is to flip the rule around, exactly as we did at the checkout: not a large loan, or income verified.

// src/loan.ts

/**
 * correct at last, but a translation we must redo for every conditional rule
 */
const meetsIncomeRequirement: P.Predicate<LoanApplication> = pipe(
  P.not(isLargeLoan),
  P.or(applicantIncomeVerified),
)

This one is right. It is also the sideways sentence from the predicate post, and it carries the predicate post's quiet hazard with it. The next conditional rule must be flipped the same way, and the one after that, and somewhere down a rulebook of hundreds a tired hand writes not large, and verified, or drops the not entirely, and ships a rule that declines the wrong people for a quarter before anyone notices. The logic is sound. The by-hand translation is not safe at scale. What we are missing is not cleverness; it is a word.

What a BooleanAlgebra is

The predicate post gave us and, or, and not, but not the one connective this rulebook is mostly built from: if, then. There is a structure that has it built in, and that brings, along with the word, the laws that make the rest of the rulebook safe to write down. We have most of its parts already. The semilattices post handed us join and meet, the least upper bound and greatest lower bound of two values, with the edges one and zero that bracket them. On a plain boolean those four are old friends in formal dress: meet is and, join is or, one is true, and zero is false. Stack them into a bounded, distributive lattice and you have nearly everything. fp-ts calls the next floor up a HeytingAlgebra, and it adds the two operations a row of plain logic operators can never quite say on its own:

interface HeytingAlgebra<A> extends BoundedDistributiveLattice<A> {
  readonly implies: (x: A, y: A) => A
  readonly not: (x: A) => A
}

implies is the very if, then we have been translating by hand, and not is a complement, the exact opposite of a value. A BooleanAlgebra is a HeytingAlgebra whose not is a true complement, the unqualified not we mean when we say it out loud:

interface BooleanAlgebra<A> extends HeytingAlgebra<A> {}

On a plain boolean that distinction dissolves: there not is the flat opposite we expect, which is why the short truth tables ahead are enough to pin these laws down for good. It is only on stranger types that a Heyting not and a Boolean not part ways, and a loan rule, in the end, is only ever true or false.

But the operations are not the prize; the laws are. implies(x, y) is, by definition, not x or y. not (x and y) is, by law, not x or not y. These are not conventions we hope someone honoured; they are guarantees the algebra makes and keeps. That is what turns the nervous by-hand translation into something the machine does for us, every time, correctly: we will write a rule in whatever words a regulation used, and lean on the laws to know we did not change its meaning.

A BooleanAlgebra for predicates

A Predicate<A> is just a function from a value to a boolean, and a function whose output is a BooleanAlgebra is a BooleanAlgebra too: to combine two such functions you simply combine their outputs, value by value. fp-ts ships exactly that lift in its function module, getBooleanAlgebra, so the boolean algebra we just described slides onto predicates with no work of our own. We give the lift a home in a little module, so any corner of the system can ask for the algebra of rules about its own type:

// src/predicate.ts
import * as BA from 'fp-ts/BooleanAlgebra'
import { BooleanAlgebra } from 'fp-ts/boolean'
import { getBooleanAlgebra } from 'fp-ts/function'
import * as P from 'fp-ts/Predicate'

/**
 * predicates form a BooleanAlgebra. a predicate is a function into boolean, so
 * the boolean algebra lifts straight onto it, bringing and, or, not, implies,
 * and the always-true and always-false rules along for the ride
 */
export const getPredicateAlgebra = <A>(): BA.BooleanAlgebra<P.Predicate<A>> =>
  getBooleanAlgebra(BooleanAlgebra)<A>()

Hand it the type the rules speak about and it gives back an algebra of those rules: meet and join, not and implies, and the always-true and always-false rules, every one of them now operating on whole predicates about an application rather than on bare booleans.

Saying it with implies

Before any code, picture what the rule does to the space of every application a lender sees. It splits them in two: the single corner it forbids, a large loan whose income is not on file, and all the rest, which it waves through. A rule that opens with if the loan is large has nothing to say about loans that are not, so every small loan passes untouched, its income never asked after.

Two overlapping circles inside the space of applications: large loans and verified income. Only the sliver of the large-loan circle that lies outside verified income is shaded and labelled declined; the overlap is checked, and every application elsewhere, including every loan that is not large, passes.

That picture is the word implies, and the rule can finally be written with it:

// src/loan.ts
import { getPredicateAlgebra } from './predicate'

/**
 * the algebra of loan rules: and, or, not, implies, and the always-true
 * and always-false rules, all speaking about a whole application
 */
const B = getPredicateAlgebra<LoanApplication>()

/**
 * a large loan must come from an applicant with verified income
 */
const largeLoanRequiresVerifiedIncome: P.Predicate<LoanApplication> = B.implies(
  isLargeLoan,
  applicantIncomeVerified,
)

Read it aloud and it is the policy: large loan implies verified income. It cannot collapse into large and verified, the way our first attempt did, because that is a different word; the trap we fell into is now unspellable. That is the first thing the algebra buys us, a name you cannot misread. The truth table says it in four rows, the same split the picture drew:

import * as assert from 'node:assert'
import { BooleanAlgebra } from 'fp-ts/boolean'

const { implies } = BooleanAlgebra

// implies(largeLoan, verifiedIncome), the four rows of inputs
const truthTable = [
  implies(true, true),   // large, income verified
  implies(true, false),  // large, income unverified: the one decline
  implies(false, true),  // not large: the rule steps aside
  implies(false, false), // not large either: still nothing asked
]

assert.deepStrictEqual(truthTable, [true, false, true, true])

Only one row is false, the row the picture marked as forbidden: a large loan whose income is not on file. The two bottom rows are the loans that are not large, true whatever the income, and the modest $5,000 borrower we wrongly declined a moment ago passes among them, asked for nothing.

A growing list of obligations

One rule proves nothing on its own; the rulebook is the point, and it never stops growing. The second rule is cut from the same cloth: an applicant from outside the home market must complete full identity verification, whatever the size of the loan. Those are facts about the applicant, so the claims live with the applicant:

// src/applicant.ts

/**
 * the applicant has completed full identity verification
 */
export const isFullyVerified: P.Predicate<Applicant> = (applicant) =>
  applicant.idVerified

/**
 * the applicant is borrowing from outside the home market
 */
export const isNonResident: P.Predicate<Applicant> = (applicant) =>
  applicant.country !== 'US'

Turned to face a whole application, the obligation is written exactly like the first one. We lift its two facts onto the application:

// src/loan.ts

const applicantIsNonResident: P.Predicate<LoanApplication> = pipe(
  Applicant.isNonResident,
  P.contramap((loan: LoanApplication) => loan.applicant),
)

const applicantIsFullyVerified: P.Predicate<LoanApplication> = pipe(
  Applicant.isFullyVerified,
  P.contramap((loan: LoanApplication) => loan.applicant),
)

then the obligation itself, the same word in the order the rule is spoken:

// src/loan.ts

/**
 * non-resident applicants must complete full identity verification
 */
const nonResidentRequiresFullId: P.Predicate<LoanApplication> = B.implies(
  applicantIsNonResident,
  applicantIsFullyVerified,
)

Here is the second thing the algebra buys us, and it is worth more than the first. Each rule is now a value, not a branch buried in an underwriting function, and values compose. “Every rule must hold” is itself an operation, the meet, with the always-true rule as its unit, the same all-must-hold fold the predicate post wrote as getMonoidAll; concatAll runs it down the whole list, the very fold that merged accounts in the Monoids post. So the whole rulebook folds down to a single predicate:

// src/loan.ts
import * as M from 'fp-ts/Monoid'

/**
 * every obligation must hold: meet is and, one is the always-true rule an empty
 * list folds to. the predicate post's getMonoidAll, rebuilt from meet and one
 */
const all: M.Monoid<P.Predicate<LoanApplication>> = { concat: B.meet, empty: B.one }

/**
 * the entire compliance policy, as one value
 */
export const meetsCompliance: P.Predicate<LoanApplication> = M.concatAll(all)([
  largeLoanRequiresVerifiedIncome,
  nonResidentRequiresFullId,
])

One value. We hand the very same meetsCompliance to the engine that approves the loan, to the report the auditor reads at quarter's end, and to the screen that tells an applicant why we said no, and the three cannot drift apart, because there is only one of them. It folds into the wider underwriting decision as a single clause, set beside whatever base eligibility the desk already checks.

Because the compliance policy is a value in its own right, we can question it alone, the way a compliance officer would. Start with a large loan whose income is not yet on file, then the same loan once the documents arrive:

import * as assert from 'node:assert'
import * as Loan from './loan'

/**
 * a $60,000 loan to a domestic applicant with no income documents on file
 */
const largeLoan: Loan.LoanApplication = {
  amount: 60_000,
  purpose: 'home improvement',
  applicant: { country: 'US', idVerified: false, incomeVerified: false },
}

/**
 * the same loan, with income now documented
 */
const largeLoanWithIncome: Loan.LoanApplication = {
  ...largeLoan,
  applicant: { ...largeLoan.applicant, incomeVerified: true },
}

const largeLoanVerdict = Loan.meetsCompliance(largeLoan)
const largeLoanWithIncomeVerdict = Loan.meetsCompliance(largeLoanWithIncome)

// unverified income: the obligation bites; documented: it is met
assert.strictEqual(largeLoanVerdict, false)
assert.strictEqual(largeLoanWithIncomeVerdict, true)

Shrink the amount and the rule lets go entirely. A modest domestic loan is asked for nothing:

const smallLoan: Loan.LoanApplication = { ...largeLoan, amount: 5_000 }

const smallLoanVerdict = Loan.meetsCompliance(smallLoan)

// the small loan our first attempt declined now passes, asked for nothing
assert.strictEqual(smallLoanVerdict, true)

But cross a border and the second obligation takes over, however small the loan:

/**
 * a modest loan from an applicant abroad who has not verified their ID
 */
const abroad: Loan.LoanApplication = {
  amount: 5_000,
  purpose: 'home improvement',
  applicant: { country: 'CA', idVerified: false, incomeVerified: false },
}

const abroadVerdict = Loan.meetsCompliance(abroad)

// crossing the border needs full ID, however small the loan
assert.strictEqual(abroadVerdict, false)

The modest domestic loan is the trap from the start of the post, now harmless: a rule that begins with if steps aside on its own when its condition is not met, so we never had to remember to let it through. implies remembered for us.

A few weeks later: a prohibition

The policy has been live a few weeks when legal arrives with a rule of a different temper, and the third thing the algebra buys us finally pays out. The earlier rules were obligations, if this, then that; this one is a flat prohibition, with no then to soften it: we may never finance a restricted purpose for an applicant in a sanctioned jurisdiction.

Two overlapping circles, restricted purpose and sanctioned jurisdiction. Only their overlap, where an application falls in both at once, is shaded and labelled never; everywhere else, in one circle or in neither, is allowed.

Set it beside the last rule's diagram and the difference is plain. An obligation forbade a crescent, a large loan stranded outside verified income; a prohibition forbids the overlap itself, the applications that fall in both circles at once. Everywhere else, in one circle or in neither, is allowed. The two lists that draw those circles come from the compliance team. First the purposes:

// src/loan.ts

/**
 * the purposes the rulebook will not finance
 */
const restrictedPurposes = new Set(['crypto', 'gambling', 'weapons'])

/**
 * this loan is for a restricted purpose
 */
const isRestrictedPurpose: P.Predicate<LoanApplication> = (loan) =>
  restrictedPurposes.has(loan.purpose)

then the jurisdictions, a fact about the applicant rather than the loan:

// src/applicant.ts

/**
 * the jurisdictions under sanction
 */
const sanctioned = new Set(['IR', 'KP', 'SY'])

/**
 * the applicant resides in a sanctioned jurisdiction
 */
export const inSanctionedCountry: P.Predicate<Applicant> = (applicant) =>
  sanctioned.has(applicant.country)

and the applicant's claim lifted to face a whole application:

// src/loan.ts

const applicantInSanctionedCountry: P.Predicate<LoanApplication> = pipe(
  Applicant.inSanctionedCountry,
  P.contramap((loan: LoanApplication) => loan.applicant),
)

A regulation rarely arrives in the shape your code would prefer. You could turn this prohibition into an if, then in your head before typing it, but that silent turn is the predicate post's hazard all over again. The algebra lets you skip it: write the rule in whichever words the regulator used, and the laws will tell you it is the same rule. Here is the one prohibition, said three ways:

// “never a restricted purpose in a sanctioned jurisdiction”
const asProhibition = B.not(B.meet(isRestrictedPurpose, applicantInSanctionedCountry))

// “no restricted purpose, or else not in a sanctioned jurisdiction”
const asDisjunction = B.join(B.not(isRestrictedPurpose), B.not(applicantInSanctionedCountry))

// “if the purpose is restricted, then not in a sanctioned jurisdiction”
const asImplication = B.implies(isRestrictedPurpose, B.not(applicantInSanctionedCountry))

We need not pick anxiously among them; they are three names for one rule. The definition of implies turns the third into the second, and De Morgan's law turns the second into the first, so confirming the rule is just confirming De Morgan, and a boolean has only four pairs of inputs. Watch it hold, row by row:

import * as assert from 'node:assert'
import { BooleanAlgebra } from 'fp-ts/boolean'

const { not, meet, join } = BooleanAlgebra

// "not (x and y)" — the prohibition — across the four rows
const prohibition = [
  not(meet(true, true)),
  not(meet(true, false)),
  not(meet(false, true)),
  not(meet(false, false)),
]

// "not x or not y" — its De Morgan twin — across the same rows
const eitherNot = [
  join(not(true), not(true)),
  join(not(true), not(false)),
  join(not(false), not(true)),
  join(not(false), not(false)),
]

assert.deepStrictEqual(prohibition, eitherNot)

Three sentences, one rule, proven. So we transcribe legal's words exactly, a prohibition, and widen the same meetsCompliance to include it, trusting the algebra that it is identical to the if, then the engine will run:

// src/loan.ts

/**
 * we may never finance a restricted purpose in a sanctioned jurisdiction
 */
const noRestrictedInSanctioned: P.Predicate<LoanApplication> = B.not(
  B.meet(isRestrictedPurpose, applicantInSanctionedCountry),
)

/**
 * the same meetsCompliance, now with the prohibition folded in beside the two
 * obligations
 */
export const meetsCompliance: P.Predicate<LoanApplication> = M.concatAll(all)([
  largeLoanRequiresVerifiedIncome,
  nonResidentRequiresFullId,
  noRestrictedInSanctioned,
])

And it holds exactly as the law promised. Hand it the one application it must refuse, a restricted purpose bound for a sanctioned jurisdiction and spotless in every other way:

import * as assert from 'node:assert'
import * as Loan from './loan'

/**
 * a loan for a restricted purpose, to an applicant in a sanctioned jurisdiction,
 * otherwise spotless: full ID, documented income, a modest amount
 */
const sanctionedCrypto: Loan.LoanApplication = {
  amount: 5_000,
  purpose: 'crypto',
  applicant: { country: 'IR', idVerified: true, incomeVerified: true },
}

const sanctionedCryptoVerdict = Loan.meetsCompliance(sanctionedCrypto)

// the prohibition bites, though every other obligation is met
assert.strictEqual(sanctionedCryptoVerdict, false)

Then two applications that sit just outside the overlap, one stepping off the sanctions list, the other trading the restricted purpose for an ordinary one, and the policy waves both through:

/**
 * the same loan, but to an applicant outside the sanctions list
 */
const cryptoToCanada: Loan.LoanApplication = {
  ...sanctionedCrypto,
  applicant: { ...sanctionedCrypto.applicant, country: 'CA' },
}

/**
 * the same applicant and jurisdiction, but an ordinary purpose
 */
const ordinaryPurpose: Loan.LoanApplication = {
  ...sanctionedCrypto,
  purpose: 'home improvement',
}

const cryptoToCanadaVerdict = Loan.meetsCompliance(cryptoToCanada)
const ordinaryPurposeVerdict = Loan.meetsCompliance(ordinaryPurpose)

// an applicant outside the sanctions list: compliant
assert.strictEqual(cryptoToCanadaVerdict, true)

// an ordinary purpose, even to the very same sanctioned jurisdiction, is untouched
assert.strictEqual(ordinaryPurposeVerdict, true)

A rule of a shape we had never handled cost one line in the list and not a single change anywhere else, because not and meet were already in the algebra, waiting. The obligations and the prohibition sit side by side in meetsCompliance, each named for the policy it stands for, each testable alone.

Conclusion

A compliance rule is a sentence the business says out loud: if this, then that; never this together with that. For years we translated each one into logic operators by hand, and the translations bit back: a conjunction where we meant an implication, turning away honest borrowers; a dropped negation at the bottom of a long rulebook. A BooleanAlgebra ends that, and it earns its keep three times over. implies gives the conditional rule, the rule a lender's book is mostly made of, a name that cannot be confused with and and that carries its own vacuous truth, so the cases a rule does not govern step aside on their own. meet and join keep every rule a value you fold into one policy and share across the whole system, so no two copies can disagree. And the laws let you transcribe a regulation in its own words and prove the code still means it. The rulebook the regulator wrote and the booleans the machine runs become, at last, provably one sentence, on every application that will ever cross the desk.

We ranked products in the sorting post, adapted rules across types in the contramap post, merged whole entities in the Monoids post, capped values in the Bounded post, combined and capped permissions in the semilattices post, and gave the rules buried in our ifs their names back in the predicate post. Here we handed those rules the rest of their grammar, and put them to work where rules multiply fastest: a lending desk.

The verdict still answers in a single word, approve or decline, and for the desk that word is enough. But the auditor at the end of the quarter does not want a word; they want the name of every rule an application broke, gathered up and handed over. A predicate, the instant it returns, throws that list away. To keep it, a rule has to start answering with something richer than a boolean, and that is a door into what comes next. The old refrain holds: define the small pieces with care, and let composition do the heavy lifting.