Field Notes

Cats Core, Part 4: Teaching Cats to Map Our Own Types

Cats Core, Part 4: Teaching Cats to Map Our Own Types

With Monoid, the useful part finally clicked: I can keep my application type and give Cats a separate rule for working with it. Cats then supplies operations that understand that rule. I wanted to follow the same idea with mapping.

The starting problem is small. I have a ten-percent discount. Sometimes the caller has a list of prices; sometimes it has one optional price. Later, I want to carry a price together with the source that supplied it. The calculation should stay the same.

One price rule, two existing containers

I start with an ordinary function from BigDecimal to BigDecimal. Then I put the container outside that business rule:

import cats._
import cats.data._
import cats.syntax.all._

def tenPercentOff(price: BigDecimal): BigDecimal =
  price * BigDecimal("0.90")

def discountPrices[F[_]](prices: F[BigDecimal])(
    implicit functor: Functor[F]
): F[BigDecimal] =
  prices.map(tenPercentOff)

The declaration F[_] says that F accepts one type argument. Here F can become List or Option. Functor[F] supplies the mapping capability for that F. The input is F[BigDecimal], and the output is another F[BigDecimal].

I keep the three exploration imports while learning so that Cats syntax is easy to discover in the IDE, then remove unused imports. The examples use Scala 2.13.18 and Cats Core 2.13.0. These prices are decimal amounts in one currency; a production pricing rule would also specify rounding.

val catalog = List(BigDecimal(12), BigDecimal(2))
val knownPrice: Option[BigDecimal] = Some(BigDecimal(12))
val missingPrice: Option[BigDecimal] = None

val discountedCatalog = discountPrices(catalog)
// List(10.80, 1.80)
val discountedKnown = discountPrices(knownPrice)
// Some(10.80)
val discountedMissing = discountPrices(missingPrice)
// None
val discountedEmpty = discountPrices(List.empty[BigDecimal])
// List()

For List, the mapping transforms each price and preserves the number and order of elements. For Option, Some contains the transformed price and None remains None. There is no price on which to call the discount function when the input is missing.

Scala already gives List and Option their own map methods. Cats contributes a common capability that my generic function can request without choosing either container. The ordinary price rule and the container behavior stay separate.

Bring an application type into the same function

My next requirement is to retain where a value came from. I introduce Quote[A]: a value of type A together with a source string. The source should survive a transformation of the value.

final case class Quote[A](value: A, source: String)

implicit val quoteFunctor: Functor[Quote] = new Functor[Quote] {
  override def map[A, B](fa: Quote[A])(f: A => B): Quote[B] =
    Quote(f(fa.value), fa.source)
}

The important line is Quote(f(fa.value), fa.source). Read it literally: take the incoming value, apply the supplied function, and build a new Quote with the original source. The original quote is unchanged.

I define Functor[Quote], because the instance must work for different contained types. In the method, A is the input value type and B is the result type. The instance contains no discount policy; the caller supplies that through f.

Quote with value 12 and source catalog passes through the custom map rule to a new Quote with value 10.80 and the same source catalog.
The application supplies the transformation. The Functor instance applies it to the value and carries the source into the new Quote.
val original: Quote[BigDecimal] =
  Quote(BigDecimal(12), "catalog")

val discounted: Quote[BigDecimal] = discountPrices(original)
// Quote(10.80,catalog)

val throughOwner: Quote[BigDecimal] =
  Functor[Quote].map(original)(tenPercentOff)
// Quote(10.80,catalog)

The existing discountPrices function accepts Quote immediately because our instance is in scope. F is now Quote. In the learning repository, lesson 12 imports the actual function from lesson 11, so this reuse is exercised directly.

The explicit Functor[Quote].map call also helps me find the owner of the operation. Quote itself declares no map method. Cats syntax makes mapping available through the instance I supplied.

The value can change type

A discount happens to return the same inner type it receives. Mapping is more general: I can turn the discounted amount into a display string.

val label: BigDecimal => String = price => s"EUR $price"

val formatted: Quote[String] = discounted.map(label)
// Quote(EUR 10.80,catalog)

The result is Quote[String]. The outer type remains Quote, and the source is still catalog. This example label is deliberately simple; it is not a complete money formatter.

This also distinguishes mapping from the aggregation in the previous article. A Foldable operation can summarize a container into one total; Functor transforms its values while keeping the outer type. Our map needs neither a Monoid nor an empty price.

The two laws are concrete checks on the rule

A function named map is not enough. The Functor contract includes identity and composition. Both rules apply to pure, total transformations.

Identity: transforming each value into itself must preserve the whole Quote.

val unchanged: Quote[BigDecimal] = original.map(price => price)
assert(unchanged == original)

For any Quote(value, source), our implementation produces Quote(value, source) again. Equality here compares values, not object identity. If I appended “mapped” to the source on every call, even this identity transformation would change the quote and violate the law.

Composition: mapping twice must agree with mapping once using the two transformations in sequence.

val discount: BigDecimal => BigDecimal = tenPercentOff

val twoMaps: Quote[String] = original.map(discount).map(label)
val oneMap: Quote[String] = original.map(discount.andThen(label))

assert(twoMaps == oneMap)
// Both: Quote(EUR 10.80,catalog)

discount.andThen(label) means discount first, then label the resulting price. Both paths take 12 to 10.80 and then to “EUR 10.80”. For arbitrary pure functions f and g, both paths through our implementation produce Quote(g(f(value)), source).

Identity leaves Quote(12,catalog) equal to itself. Two maps, discount then label, and one composed map both produce Quote(EUR 10.80,catalog).
Identity checks the unchanged transformation. Composition checks that splitting a transformation into two maps preserves the result.

The assertions exercise particular examples. They do not prove every possible input. The implementation explains the general behavior: the value receives the same function applications, and the source is carried through unchanged.

Run the complete example

The seven Scala blocks form one complete sequence. In a project with Scala 2.13.18 and Cats Core 2.13.0, run sbt console, enter :paste, paste the blocks in order, then finish with Ctrl+D. The final assertions should pass.

I compiled and ran the article sequence separately, including checks for List, Some, None, an empty List, the custom Quote, and both mapping laws. The learning repository supplies the pinned sbt build. This article contains every declaration needed for its examples.

The useful idea is the same one I liked with Monoid: my type supplies an instance, and an existing generic function gains another type it can work with. With Functor, the rule is how to apply a transformation inside that type.

Reference: Typelevel’s Functor documentation. Continue through the Cats Core series.