Functor
, or covariant functor, is a typeclass that defines the map
operation, which takes a F[A]
and a transformer function A => B
to return a F[B]
.
trait Functor[F[_]]:
def map[A, B](fa: F[A])(f: A => B): F[B]
A contramap
is defined as the converse of the map
. I have never been satisfied with the definition. For starters, it is not defined in a Functor
. So, where does contramap
appear? It appears in a contravariant functor1 (Contravariant
in cats).
trait Contravariant[F[_]]:
def contramap[A, B](fa: F[A])(f: B => A): F[B]
The definition of contramap
using converse is used mainly because of the direction of the arrow (A => B
vs B => A
). It is not wrong but it is not easy to understand and explain.
A common example used for contramap
is the Encoder
such as when using circe.
import io.circe._
import io.circe.syntax._
class Person(val name: String, val age: Int)
object Person:
implicit val encoder: Encoder[Person] = (p: Person) =>
Json.obj(
("name", Json.fromString(p.name)),
("age", Json.fromInt(p.age))
)
class Employee(
override val name: String,
override val age: Int,
val employeeId: String
) extends Person(name, age)
object Employee:
implicit val encoder: Encoder[Employee] = Person.encoder.contramap(identity)
@main
def runEmployee(): Unit = {
val employee = Employee("John Doe", 30, "E123")
val employeeJson = employee.asJson
println(employeeJson)
}
Reading the code above, it is not easy to explain what is going on with contramap
.
In this post, I will explain the concept in a way that is easy to internalize.
Here is a motivating example:
trait Validator[-A]:
def validate(a: A): Boolean
// Defining a new Validator[B] using Validator[A] given a function that converts B to A.
// 1. convert type B to type A using f
// 2. validate the value of type A using Validator[A]
def contramap[B](f: B => A): Validator[B] = (b: B) => validate(f(b))
def &&[B <: A] (that: Validator[B]): Validator[B] =
(value: B) => this.validate(value) && that.validate(value)
object Validator:
def validate[A: Validator](value: A): Boolean =
summon[Validator[A]].validate(value)
import User.{Name, Age}
case class User(name: Name, age: Age)
object User:
case class Name(value: String)
object Name:
given Validator[Name] = _.value.nonEmpty
case class Age(value: Int)
object Age:
given Validator[Age] = _.value > 0
given Validator[User] =
summon[Validator[Name]].contramap[User](_.name) &&
summon[Validator[Age]].contramap[User](_.age)
@main
def runValidator(): Unit = {
val user1 = User(Name("Alice"), Age(30))
println(Validator.validate(user1)) // Output: true (valid user)
val user2 = User(Name(""), Age(25)) // Invalid name
println(Validator.validate(user2)) // Output: false (invalid user)
val user3 = User(Name("Bob"), Age(-5)) // Invalid age
println(Validator.validate(user3)) // Output: false (invalid user)
}
We have the validators for name and age (consider it Validator[A]
). Now, we are defining Validator[User]
2 (consider it Validator[B]
) using the aforementioned validators, which is where contramap
comes into play.
Contramap allows you to transform a function by changing the input type without changing the operation itself. In other words, it takes a function that works on one type and adapts it to work with another type by transforming the input, without changing the underlying operation.
A point to note is that contramap
is relevant only when contravariance is in the picture which is why a Functor
does not define it. That means only typeclasses taking parameters that are consumed rather than produced.
contramap
is not well understood because it does not appear in abundance unlike our beloved map
. Hope this post makes it easy to internalize contramap
.
-
There are other types of Functors - Invariant, Applicative, Bifunctor, etc. They are not relevant in this post. ↩︎
-
Thanks to Morgen Peschke for perfecting the
Validator
example, especially using thesummon[Validator[A]]
, which makes the API more natural. ↩︎