Contramap in the real world: adapting rules across types with fp-ts
On every e-commerce site there is a quiet little daily ritual where the support team disables a category. A line of beach umbrellas has gone out of season, or a vendor has folded and their entire shelf has to come down for the weekend. Flipping the boolean on the Category itself does the operational part. The harder part is everything that lives downstream of that flag.
Products in the disabled category should disappear from the catalogue. Reviews left on those products should be hidden from the public until the category comes back. A recommendation engine should stop suggesting them. A wishlist screen should mark them as no longer available. An order placed before the disable went out should be flagged for the support agent who picks up the conversation.
Read aloud, the rule sounds like it ought to be one rule. Written by hand it usually grows into four or five.
There is one predicate in the Category module that reads category.disabled, another in Product
that reaches into product.category.disabled, a third in Review that goes one level deeper still,
and so on for every type that carries a category somewhere inside it. The hand-written version works, and
each predicate is short enough on its own. The cost is that the same fact is now told in four different
places, and when the rule of being disabled changes the change has to be made in every one of them.
contramap is the small adapter that turns those four predicates back into one. You write the rule
on the type that owns it, and every other type that carries one borrows the rule by pointing at the field
where its category lives.
Modeling the domain
A category, a product, and a review, each in the module that owns it. A category carries a flag for whether it is currently in service:
// src/category.ts
/**
* a tag on the shelf, with a quiet switch for whether it is in service
*/
export interface Category {
readonly id: string
readonly name: string
readonly disabled: boolean
}
// src/product.ts
import { Category } from './category'
/**
* an item on the shelf, with the category it belongs to
*/
export interface Product {
readonly id: string
readonly name: string
readonly category: Category
}
// src/review.ts
import { Product } from './product'
/**
* a star rating a customer left on a product they bought
*/
export interface Review {
readonly id: string
readonly product: Product
readonly rating: number
readonly body: string
}
What contramap is
In fp-ts, contramap is the same operation found on
many modules: Ord, Eq, Predicate, Show, and so on. The signature is the same every time, and it is as small
as it gets:
const contramap: <A, B>(f: (b: B) => A) => (instance: Instance<A>) => Instance<B>
You hand it a function from B to A, together with an instance that already knows
about A, and you get back an instance that knows about B. The instance has not
changed and the function has not changed; what contramap returns is a new instance pointed at
whichever B you wanted to operate on, with the original A still doing all the work
underneath.
The arrows in the picture point in opposite directions, and that is the entire sense of the word contra
in the name. The value flows one way, from a B outward to its A. The capability
flows the other way, from an instance on A inward to an instance on B. Two cars,
one bridge, moving in opposite directions and never quite meeting.
Pointing an ordering at a category
In the sorting post we used contramap once,
briefly, to turn an ordering on strings into an ordering on categories. Set the surrounding Ord story aside
for a moment and look only at that single step:
// src/category.ts
import * as O from 'fp-ts/Ord'
import * as S from 'fp-ts/string'
import { pipe } from 'fp-ts/function'
/**
* categories are ordered alphabetically by their name
*/
export const Ord: O.Ord<Category> = pipe(
S.Ord,
O.contramap((category: Category) => category.name),
)
The string knows how to compare itself. The category does not. The little arrow function in the middle, the
one reaching into a category for its name, is the entire adapter. It does not touch the string
ordering and it does not touch the category. It pairs the two by saying, plainly, where in a category the
string we already know about can be found.
Lifting the rule across types
The same shape repeats anywhere a typeclass instance lives next to a type that carries the one it already knows about. The Predicate module is the clearest place to watch it happen. A predicate is just a function from a value to a boolean, the smallest true-or-false test you can write, and the rules of a business are almost entirely made of them.
Start with the rule on the type that owns it. A category is disabled when its flag says so, and that is the whole sentence:
// src/category.ts
/**
* the category has been switched off
*/
export const isCategoryDisabled = (category: Category): boolean => category.disabled
That little function is already a Predicate<Category>, because the type lines up: a
function from a Category to a boolean. Now Product. A product is unavailable when its category is disabled.
We already know how to answer that question about a category, and a product carries one inside it. The
travelling adapter shows up here for the first time wearing the colours of the Predicate module:
// src/product.ts
import * as P from 'fp-ts/Predicate'
import { pipe } from 'fp-ts/function'
import { isCategoryDisabled } from './category'
/**
* a product is unavailable when its category has been switched off
*/
export const isProductUnavailable: P.Predicate<Product> = pipe(
isCategoryDisabled,
P.contramap((product: Product) => product.category),
)
The category rule has not changed, the product has not changed, and yet a question we knew how to ask about a
category we now know how to ask about a product. The same little arrow function, pointed at the
category field of a product, was all it took. Reviews follow the same path, one type further
out. A review is inactive when the product it is about is unavailable, and a review carries a product, so
the adapter we need is the small function from a review to its product:
// src/review.ts
import * as P from 'fp-ts/Predicate'
import { pipe } from 'fp-ts/function'
import { isProductUnavailable } from './product'
/**
* a review is inactive when the product it is about is unavailable
*/
export const isReviewInactive: P.Predicate<Review> = pipe(
isProductUnavailable,
P.contramap((review: Review) => review.product),
)
Three modules now, each owning its own little rule, each module borrowing the rule of the one nested inside it and turning the predicate outward by exactly one type at a time:
And at the call site each predicate reads like the rule it enforces. The catalogue page filters products with
the negation of isProductUnavailable, the moderation queue uses
isReviewInactive directly, and neither has to repeat what either rule means:
import { pipe } from 'fp-ts/function'
import * as P from 'fp-ts/Predicate'
import * as RA from 'fp-ts/ReadonlyArray'
import { isProductUnavailable } from './product'
import { isReviewInactive } from './review'
const visibleProducts = pipe(
products,
RA.filter(P.not(isProductUnavailable)),
)
const inactiveReviews = pipe(
reviews,
RA.filter(isReviewInactive),
)
Two adapters, in one breath
Because contramap is a small operation and composes cleanly with itself, the same review
predicate can be written in one continuous step that reaches all the way from a review down into its
category. Each contramap in the pipe is one ring outward, one type further from the rule's
natural home:
// src/review.ts
import * as P from 'fp-ts/Predicate'
import { pipe } from 'fp-ts/function'
import { Category, isCategoryDisabled } from './category'
import { Product } from './product'
/**
* the same predicate, written by stacking two adapters in a single pipe:
* from a review to its product, and from a product to its category
*/
export const isReviewInactive: P.Predicate<Review> = pipe(
isCategoryDisabled,
P.contramap((product: Product): Category => product.category),
P.contramap((review: Review): Product => review.product),
)
It reads cleanly as a teaching version, less so as the version you live with. The intermediate name,
isProductUnavailable, is worth keeping in its own module, where the catalogue page and the
wishlist and anything else that has a Product to filter can borrow it without having to drag a Review along
for the ride. Composition does not have to be greedy.
A few weeks later
The rule has been live for a few weeks when a new type joins the system. The recommendations team is shipping
a Suggestion, a small entity that wraps a product with a score and a justification string for
analytics:
// src/suggestion.ts
import { Product } from './product'
/**
* a product the recommender thinks the customer might like, with a score
*/
export interface Suggestion {
readonly product: Product
readonly score: number
readonly reason: string
}
A suggestion that points at an unavailable product should never reach the customer. The recommender team asks for a single predicate they can filter their results with, and the predicate is one line. The category rule has nothing to learn from any of this; the product rule does not move; only the new module writes a new sentence, in its own vocabulary, by pointing the existing rule at the field where the answer it already knew lives:
// src/suggestion.ts
import * as P from 'fp-ts/Predicate'
import { pipe } from 'fp-ts/function'
import { isProductUnavailable } from './product'
/**
* a suggestion is stale when the product it points to is unavailable
*/
export const isSuggestionStale: P.Predicate<Suggestion> = pipe(
isProductUnavailable,
P.contramap((suggestion: Suggestion) => suggestion.product),
)
And at the call site the filter reads like the rule it enforces, the same shape we used for products and reviews:
import { pipe } from 'fp-ts/function'
import * as P from 'fp-ts/Predicate'
import * as RA from 'fp-ts/ReadonlyArray'
import { isSuggestionStale } from './suggestion'
const fresh = pipe(
suggestions,
RA.filter(P.not(isSuggestionStale)),
)
Nothing about the category rule moved. Nothing about the product rule or the review rule moved. The new type
took its place in the system the same way every type before it had: one module, one function inside it, one
line of contramap pointing at the field where the rule it borrows already lives. The day the
business decides to disable a category for a different reason, that change is made in one place and ripples,
without anyone touching it, through every type that carries one.
Conclusion
contramap is the travelling adapter we packed at the start, brought back out for the conclusion.
It does not change your charger and it does not rewire the wall: it sits between two shapes that do not
naturally fit and lets the capability of one cross over into the world of the other. You stop rewriting
ordering rules and predicates for every new type that arrives, and you start pointing the rules you already
have at the field where the value they know about lives. The number of types that can carry a category is
unbounded; the number of times you have to write the rule is one.
In the sorting post we composed orderings into a single ranking; here we showed how a single rule, written once, reaches every type that carries the value it knows about. The pattern is always the same: define small pieces with care, and let composition do the heavy lifting.