Vidal Vasconcelos
← Back to home

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.

Diagram showing the power adapter analogy: Category (the plug) connects through contramap (the adapter) to Ord of string (the socket), producing Ord of Category as the result

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>.

Diagram showing contramap data flow: the adapter function goes from Category to string (forward), while the capability flows from Ord of string back to Ord of Category (reversed, contravariant)

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)
)
Diagram showing how the isCategoryDisabled predicate propagates through three domain modules via contramap: Category to Product to Review, with adapter functions extracting nested types and capability flowing in reverse

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.