Bounded in the real world: capping discounts with fp-ts
On every checkout there is a small quiet drama playing out in the background. A customer has earned a loyalty perk, the season is on sale, and a coupon has been pasted in from a forum somewhere, and three different rates are now sitting in a tray waiting to learn which one ends up on the receipt. And somewhere in the codebase there is also a quiet fear, never quite spoken aloud, that one of those rates will be wrong by a digit: a buggy coupon that applies 120%, a negative rate that pays the customer to take the goods. What the business wants is simple to say in a sentence: keep the rate inside a fixed range, 0% at the bottom and 80% at the top, and let the best applicable promotion win within it.
Written by hand the calculation works. The Math module has everything we need: pick the highest rate, pull
anything out of range back between the two limits, guard the empty cart. There is nothing wrong with that
little function. But each of those steps is something the domain already knows about a discount, how it
compares, what its limits are, what it defaults to, and once we say those facts out loud the calculation comes
back as a single line that reads like the policy itself. fp-ts has a name for the missing piece,
Bounded, and Bounded earns its keep anywhere a value has to stay between a floor and
a ceiling.
Modeling the domain
A discount is more than a number. It carries a kind so that support, reading a receipt months later, can see why a rate was applied. It lives in its own module:
// src/discount.ts
/**
* a discount applied at checkout, with the reason it was applied
*/
export interface Discount {
readonly kind: string
readonly rate: number
}
Two discounts compare by their rate, and the range from a moment ago, 0% to 80%, gives us a floor
and a ceiling. Those two numbers are the guardrails we are about to hand to Bounded.
What Bounded is
Start from the Ord we already know. On its own it does exactly one thing: place any two values in
order, telling you whether the first is less than, equal to, or greater than the second:
interface Ord<A> {
readonly compare: (first: A, second: A) => Ordering
}
fp-ts builds Bounded right on top of that, and the
word extends here is worth pausing on. Bounded never redefines what comparison
means; it inherits it. Whatever Ord you already have flows straight in, the string ordering that
sorted products a few posts back, or the one we are about
to hand Discount. Nothing is rewritten.
On top of that borrowed comparison, Bounded adds the two facts that turn an ordering into a range:
the greatest value the type is allowed to take, and the least:
interface Bounded<A> extends Ord<A> {
readonly top: A
readonly bottom: A
}
That is the whole interface. top and bottom only carry meaning because the inherited
Ord can position every other value relative to them. Bounded is not a new concept to learn; it is
an Ord enriched with its own edges. Once a domain type knows how to compare itself, making it
bounded adds only the two values that mark where its range begins and ends.
A Bounded for the discount
We do not have to teach Discount how to compare itself from scratch. fp-ts already ships an Ord
for number, and the travelling adapter we packed back in
the contramap post is still in the
bag. A discount carries a number inside it; we point the number's ordering at that number, and the discount
has learned to compare itself by exactly what we wanted to compare on:
// src/discount.ts
import { pipe } from 'fp-ts/function'
import * as N from 'fp-ts/number'
import * as O from 'fp-ts/Ord'
/**
* two discounts are compared by their rate
*/
export const byRate: O.Ord<Discount> = pipe(
N.Ord,
O.contramap((discount: Discount) => discount.rate),
)
Now the endpoints. They are not bare numbers; they are full Discount values, and they earn their
own names in the domain's vocabulary:
// src/discount.ts
/**
* what a checkout gets when no promotion applies
*/
export const none: Discount = { kind: 'none', rate: 0 }
/**
* the policy ceiling: the highest rate the business will ever allow
*/
export const cap: Discount = { kind: 'cap', rate: 80 }
A Bounded for Discount is then that borrowed ordering with the two endpoints
attached. byRate is an object carrying a compare, so spreading it supplies the
Ord half of the interface; bottom and top complete it:
// src/discount.ts
import * as B from 'fp-ts/Bounded'
/**
* the policy: a comparable discount with a floor and a ceiling
*/
export const policy: B.Bounded<Discount> = {
...byRate,
bottom: none,
top: cap,
}
Notice that we never wrote a compare function by hand. The comparison logic came from the
number primitive; we only supplied two domain values to mark where the range begins and ends.
Clamping strays back into range
With the guardrails in place, forcing a value into the legal range is one call. The Bounded module ships
clamp for exactly that, and it behaves like a posted speed limit: ask for something past the
limit and you get back the nearest value that is allowed, not the one you asked for. Hand it a
Bounded and you get a function that pulls any value to the nearest endpoint, bottom
if it is too low, top if it is too high, untouched if it is already inside.
Inside the discount module that operation gets a name. Clamping a rate is really just keeping it within what the business allows, so that is what we call it:
// src/discount.ts
import * as B from 'fp-ts/Bounded'
/**
* bring any proposed discount within what the business allows
*/
export const enforcePolicy = B.clamp(policy)
From anywhere else we reach for it through the module namespace, and the call reads like the rule it enforces rather than the mechanics underneath:
import * as assert from 'node:assert'
import * as Discount from './discount'
const inRange = Discount.enforcePolicy({ kind: 'coupon', rate: 25 })
const overCap = Discount.enforcePolicy({ kind: 'coupon', rate: 120 })
const belowFloor = Discount.enforcePolicy({ kind: 'coupon', rate: -5 })
// within policy, unchanged
assert.deepStrictEqual(inRange, { kind: 'coupon', rate: 25 })
// over the cap, pulled down to the ceiling
assert.deepStrictEqual(overCap, { kind: 'cap', rate: 80 })
// below zero, pulled up to the floor
assert.deepStrictEqual(belowFloor, { kind: 'none', rate: 0 })
One small detail to be aware of: because the ordering compares only the rate, clamping the buggy
120% coupon returns the whole cap value, so its "coupon" kind is replaced
by "cap". That is what we want: you asked to be pulled to the ceiling, and the ceiling is a
complete Discount. contramap adapts how values are compared, not what they
are.
Picking the best discount
Clamping takes care of the ceiling. Choosing the winner is the other half. In
the Monoids post we saw that a Monoid combines
two values and carries an empty neutral element. Bounded hands us one for free: the
Monoid module's max turns any Bounded into a “largest wins” Monoid whose
empty is bottom. That empty is why max asks for a
Bounded rather than a plain Ord: a fold needs a value to start from when the list
runs out, and nothing is ever smaller than bottom.
// src/discount.ts
import * as M from 'fp-ts/Monoid'
/**
* the largest applicable discount wins, with none as the floor
*/
const bestOf: M.Monoid<Discount> = M.max(policy)
The empty element is what removes the awkward corner case. Folding an empty list of promotions
does not throw and does not return undefined; it returns none, a perfectly valid 0%
discount. concatAll is that fold: it runs the whole list through the Monoid down to a single
discount, and enforcePolicy then clamps it:
// src/discount.ts
import { pipe } from 'fp-ts/function'
import * as M from 'fp-ts/Monoid'
/**
* take the best promotion the customer earns, then hold it to the policy cap
*/
export const best = (discounts: ReadonlyArray<Discount>): Discount =>
pipe(
discounts,
M.concatAll(bestOf),
enforcePolicy,
)
best is two steps, the fold then the clamp, applied to its argument. Where pipe
threads a value through its steps, flow glues the steps into a new function still waiting for its
input, so the named discounts falls away:
// src/discount.ts
import { flow } from 'fp-ts/function'
/**
* the same operation, with the input named only at the call site
*/
export const best = flow(M.concatAll(bestOf), enforcePolicy)
The behaviour lives next to the instances it depends on, so the call site stays small:
import * as assert from 'node:assert'
import * as Discount from './discount'
const highest = Discount.best([
{ kind: 'loyalty', rate: 10 },
{ kind: 'seasonal', rate: 25 },
{ kind: 'coupon', rate: 5 },
])
const empty = Discount.best([])
const clamped = Discount.best([{ kind: 'coupon', rate: 120 }])
assert.deepStrictEqual(highest, { kind: 'seasonal', rate: 25 })
assert.deepStrictEqual(empty, { kind: 'none', rate: 0 })
assert.deepStrictEqual(clamped, { kind: 'cap', rate: 80 })
None of these pieces is heavier than the arithmetic it replaces. contramap, max, and
clamp are a line each, and unlike a buried Math call, every one of them is named, reusable, and
testable on its own. The simplicity was not something we added on top; it fell out of writing down what a
discount already is.
A few weeks later: breaking ties
The cap has been live for a few weeks when support flags a case the launch never covered. Two promotions sometimes land on the same rate, a loyalty perk and a pasted coupon both at 25%, and which one the receipt ends up naming looks, from where they sit, like a coin toss. The business has an answer it has always had on paper: at an equal rate, the promotion it ranks higher should win, loyalty ahead of a seasonal sale, a seasonal sale ahead of a coupon.
Comparing by rate alone has nothing left to say once two rates match, and a plain
string kind cannot express which promotion should win. A promotion is its own concept, with its
own order of preference, so it earns its own module. The kinds are listed once, least to most preferred, the
type is read straight off the array, and Promotion takes an ordering of its own: each one ranked
by its place in the list, borrowing N.Ord through the
contramap we already know:
// src/promotion.ts
import { pipe } from 'fp-ts/function'
import * as N from 'fp-ts/number'
import * as O from 'fp-ts/Ord'
/**
* every promotion, least to most preferred, with the policy's
* own floor (none) and ceiling (cap) bracketing the ones a
* checkout actually earns
*/
const promotions = ['none', 'coupon', 'seasonal', 'loyalty', 'cap'] as const
export type Promotion = (typeof promotions)[number]
/**
* a promotion's place in the list is its precedence
*/
export const precedence = (promotion: Promotion): number =>
promotions.indexOf(promotion)
/**
* promotions are ordered by precedence
*/
export const byPrecedence: O.Ord<Promotion> = pipe(
N.Ord,
O.contramap(precedence),
)
The array is the single source of truth: the kinds are listed once and the type is read straight back off them,
so the two can never drift apart. A promotion's place in that list is its precedence, and because the winner
comes from max, a later place outranks an earlier one, which is why loyalty sits
last and beats seasonal on a tie.
Now Discount never relearns that order. It borrows Promotion's ordering wholesale and
adapts it onto a discount with one more contramap, reading the kind off each one:
// src/discount.ts
import { pipe } from 'fp-ts/function'
import * as O from 'fp-ts/Ord'
import * as Promotion from './promotion'
export interface Discount {
readonly kind: Promotion.Promotion
readonly rate: number
}
/**
* two discounts of equal rate are broken by their promotion's precedence
*/
const byPrecedence: O.Ord<Discount> = pipe(
Promotion.byPrecedence,
O.contramap((discount: Discount) => discount.kind),
)
While we are here, kind stops being a plain string and picks up
Promotion.Promotion, so a typo in a kind becomes a compile error rather than a silent tie-break
bug.
The two orderings stack the way the sorting post showed:
O.getMonoid gives a Monoid whose values are themselves orderings, and combining them runs the
first and consults the next only when the first reports a tie. Rate goes first and precedence second, and the
order is load-bearing: swap them and the ranking is by promotion first. We feed that combined ordering to
max with the same floor and ceiling as before, since the rate-only policy cannot
supply the new comparison:
// src/discount.ts
import * as M from 'fp-ts/Monoid'
/**
* rate first, precedence as the tie-breaker
*/
const byPolicy: O.Ord<Discount> = M.concatAll(O.getMonoid<Discount>())([byRate, byPrecedence])
/**
* the same largest-wins Monoid, now with the tie-breaker folded in
*/
const bestOf: M.Monoid<Discount> = M.max({ ...byPolicy, top: cap, bottom: none })
Notice what did not move. best still folds with bestOf and still hands the result to
enforcePolicy; not a line of it changes. The cap goes on comparing rates alone, so clamping and
the floor and ceiling behave just as before. Only the choice of winner got sharper:
import * as assert from 'node:assert'
import * as Discount from './discount'
const winner = Discount.best([
{ kind: 'seasonal', rate: 25 },
{ kind: 'loyalty', rate: 25 },
{ kind: 'coupon', rate: 5 },
])
assert.deepStrictEqual(winner, { kind: 'loyalty', rate: 25 })
The kind arrived as a courtesy to support, a way to read why a rate had applied. It now
decides which rate applies. The field added so a person could explain the outcome turned out to hold
the rule that produces it. And the launch code never moved: a small module and one more
contramap were enough to fold a brand-new rule into what was already there.
Conclusion
Bounded works like a guardrail: the limits a value may take stop being conditionals scattered
through the code and become part of the type itself. The rule is written down once, in one place and in the
domain's own words, so reading the code means reading the policy rather than reconstructing it from a tangle
of branches, and when something goes wrong there is a single, small, testable place to look.
Extending it costs almost nothing. When a new rule shows up you write one more small piece and set it beside the ones already there, and nothing that works today has to move to make room. In the sorting post we ranked products, in the contramap post we adapted primitives across types, and in the Monoids post we merged entire entities. Here we gave a value its limits. The pattern is always the same: define small pieces with care, and let composition do the heavy lifting.