Contramap, stop rewriting and start adapting
The goal of this post is to take a closer look at contramap without going into the formal world of
Contravariant Functors. We'll keep things grounded in real code and focus on
how this small helper can make everyday work easier to reason about.
Imagine you're traveling abroad. You've got your phone charger, the wall has its socket, and the only problem is that the plug doesn't fit. Luckily, you packed a power adapter. It doesn't change your charger or the wall. It simply helps one connect to the other.
That is the same idea behind contramap. It sits in the middle of two shapes that don't naturally
match and helps them work together without changing anything on either side. If you come from an OOP background,
it echoes the spirit of the Open/Closed Principle: you extend behavior without modifying what already
works.
To see this in practice, let's revisit the earlier post about composing sorting rules.
This time we'll focus on the contramap step and set aside the details about Ord. In this setup, the
socket is an Ord<string> that knows how to compare strings, while your plug is a Category,
which is clearly not a string. contramap becomes the adapter that allows the two to connect
perfectly.
import {pipe} from 'fp-ts/function'
import * as s from 'fp-ts/string'
const ordCategoriesAlphabetically: Ord.Ord<Category> = pipe(
s.Ord,
Ord.contramap((category: Category): string => category.name),
)
The adapter here is the function that turns a Category into a string. That string is
not random; it follows the rules of the domain. Once the adapter is in place, much like a power adapter, the
Categories can now be ordered alphabetically without changing the original
Ord<string>.
The same pattern shows up in other modules. The Predicate
module is a good example because it helps you model rules, filters, and guards
that capture your business logic. These tiny pieces feel simple, but they can be used to build complex logic.
Let us turn that into something concrete. Say categories can be enabled or disabled, and any product whose category is disabled must be considered unavailable. We can model these rules with types:
interface Category {
readonly id: string
readonly name: string
readonly disabled: boolean
}
interface Product {
readonly id: string
readonly name: string
readonly category: Category
}
We can translate the two rules as follows:
// Category module
const isCategoryDisabled = (category: Category): boolean => category.disabled
The second rule tells us that a product with a disabled category must count as unavailable. We already know how
to identify disabled categories, so we would like to reuse that knowledge for products. The isCategoryDisabled
function expects a Category, while the new predicate receives a Product. A small
adapter that extracts the category is all we need, and contramap provides it:
import * as P from 'fp-ts/Predicate'
import {pipe} from 'fp-ts/function'
import {isCategoryDisabled} from './category'
// Product module
const isProductUnavailable: P.Predicate<Product> = pipe(
isCategoryDisabled,
P.contramap((product: Product): Category => product.category)
)
Now imagine a bug report arrives: reviews for unavailable products should be hidden, so any review
linked to an unavailable product must be marked inactive. The same pattern repeats. We already have a
predicate that tells us if a product is unavailable, and we need to adapt the argument from a
Review to a Product:
// Review module
import * as P from 'fp-ts/Predicate'
import {pipe} from 'fp-ts/function'
import {isProductUnavailable} from './product'
interface Review {
readonly product: Product
}
const isReviewInactive: P.Predicate<Review> = pipe(
isProductUnavailable,
P.contramap((review: Review): Product => review.product)
)
For teaching purposes, we can inline every step so the bridge's behavior becomes crystal clear:
// Review module
import * as P from 'fp-ts/Predicate'
import {pipe} from 'fp-ts/function'
import {isCategoryDisabled} from './category'
interface Review {
readonly product: Product
}
const isReviewInactive: P.Predicate<Review> = pipe(
isCategoryDisabled,
P.contramap((product: Product): Category => product.category),
P.contramap((review: Review): Product => review.product),
)
Conclusion
contramap works like that travel adapter you always pack. It bridges shapes that don't naturally
fit, keeping what already works untouched. Instead of rewriting your logic for every new type, you adapt the
input to match what you already have. Once you start thinking this way, composition stops being an abstract
principle and becomes a habit. You spend less time rewriting and more time connecting the pieces that are
already there.