Putting it all together with Type Classes
The Final chapter on the Scala Implicits Journey
Now that we have explored the “three implicits” in Scala, it’s time to put everything together in a way show the deep and profound way that Scala can simplify complex problems. (If you haven’t read the previous three articles and aren’t familiar with implicit classes or implicit parameters, you may want to start here.)
In the last article, we were using the Java NumberFormat
class, which was doing two things for us:
- Parsing a string to get a number (e.g. “103.4%” → 1.034)
- Turning a number into a formatted string (e.g. 2 → “200%”)
This creates an abstraction where one NumberFormat object may work with percentages, another may work with currencies, a third may be used to convert numbers into strict scientific notation, etc. We were passing this “helper” as an implicit parameter into our methods.
Now let’s try to abstract this problem a little bit. I’m going to introduce a type class:
trait Converter[T] {
def parse(s: String): T
def format(o: T): String
def formalFormat(o: T): String =
"The answer is " + format(o)
}
This “Converter” isn’t a class itself, but it can be used to generate concrete classes. Most readers will see the [T]
type definition and recognize it as something similar to Java generics or C++ templates. We are declaring three methods, and even providing a concrete implementation for the third one. If anyone wants to create a converter, he or she just has to provide the implementation for parse
and format
. There are two equivalent ways we can create a Converter leveraging this Java NumberFormat class:
class NumberConverter(f: NumberFormat) extends Converter[Number] {
override def parse(s: String): Number = f.parse(s)
override def format(o: Number): String = f.format(o)
}
or we can make a class-generating function using this form, where we use the new
keyword to generate a class implementation on the spot.
def numberConverter(f: NumberFormat) = new Converter[Number] {
override def parse(s: String): Number = s.toDouble
override def format(x: Number): String = f.format(x)
}
Now we aren’t limited to converting numbers. If we had a Color class that internally represented colors with their Red/Green/Blue components:
case class Color(red: Double, green: Double, blue: Double)
Then we could create a ColorConverter like this. (Please forgive the silly implementation.)
object ColorConverter extends Converter[Color] {
override def parse(s: String): Color = s match {
case "Red" => Color(1.0, 0.0, 0.0)
case "Green" => Color(0.0, 1.0, 0.0)
case "Blue" => Color(0.0, 0.0, 1.0)
case _ => Color(0.0, 0.0, 0.0)
}
override def format(o: Color): String = o match {
case Color(1.0, 0.0, 0.0) => "Red"
case Color(0.0, 1.0, 0.0) => "Green"
case Color(0.0, 0.0, 1.0) => "Blue"
case _ => "Unknown Color"
}
}
Pimp My Library
The type class that I’ve shown above may seem somewhat interesting, but I’m not going to blame you if you’re scratching your head at this point, wondering “so what?” But we’re going to combine this with two of the implicit sisters to create a design pattern that is sometimes called “pimp my library”.
We have two goals:
- To seamlessly “attach” functionality to classes without touching the classes themselves, and
- To write generic code that can be applied to many different classes without repetition.
Putting it another way, we want to build reusable libraries of general functionality that are modular, that free us of boilerplate, and that don’t make us change our base classes.
This is where Functional Programming starts becoming magical!
Putting it all together in 3 Steps
- Declare the Type Class
- Create a type-specific implementation (and have the implementation be an implicit value so we can inject it in step 3)
- Use implicit classes to bolt the implementation into the original target classes
et voila! Magic!
We started with our original Converter[T]
type class:
trait Converter[T] {
def parse(s: String): T
def format(o: T): String
def formalFormat(o: T): String = "The answer is " + format(o)
}
Now inside our companion object, let’s use implicit classes to “bolt on” this converter functionality to strings (for parse) and to any objects of type T (for format):
object Converter {
implicit class StringOps(s: String) {
def parse[T](implicit c: Converter[T]): T = c.parse(s)
}
implicit class ConvOps[T](o: T) {
def format (implicit c: Converter[T]): String = c.format(o)
def formal(implicit c: Converter[T]): String = c.formalFormat(o)
}
}
Notice that we’re using implicits two ways. We’re first using implicit classes to bolt the functionality, and we’re using implicit parameters to essentially say “assuming an implicit val (implementation) of Converter[T]
is in scope somewhere, we’ll seemingly pull this ‘helper object’ out of thin air to provide the functionality.”
We can, for example define our ColorConverter
and NumberConverter
somewhere in our code base:
implicit val color = ColorConverter
implicit val percent = new
NumberConverter(NumberFormat.getPercentInstance())
And we’re done!
The hard part is over. Now the functionality is super-easy to use!
import example.Converter._
import example.Colorscala> val color = "Red".parse[Color]
val color: Color = Color(1.0,0.0,0.0)scala> val pct = "130%".parse[Number]
val pct: Number = 1.3scala> val colorStr = Color(0.0, 1.0, 0.0).format
val colorStr: String = Greenscala> val pctStr = pct.formal
val pctStr: String = The answer is 130%
The Scala compiler just instantly knows what classes can get “pimped out” based on the availability of implicitly defined Converter instances. Libraries like Cats uses this technique extensively to build functionality out of things like Monoids and Monads. If you know your class has a concept of zero and addition, you can implement those as a Monoid, and all of a sudden you get a whole bunch of magical functionality.
And in fact, we’re going to do something similar right now.
A more practical example
In the corners of the base Scala library (in the scala.math
package), there is a Numeric[T]
type class with defined implicit instances for Float, Integer, Double, and Long.
The purpose of Numeric[T]
is to provide functionality for doing things that you would expect of a generic number: adding, multiplying, comparing, negating, etc. It also defined concepts for the numbers zero and one so that you can do things like summing arbitrary lists of numbers—i.e. if you are going to foldLeft
something, you have to have a ‘zero’ element to use as the seed.
Anyway, the only thing we are concerned with is the fact that the Numeric[T]
typeclass provides a def plus(T, T): T
method that we’re going to make use of.
In our article on implicit classes, we made an extension to List[Double]
that defined a + operator that would perform a pair-wise addition of lists. I.e.
implicit class Ops(list: List[Double]) {
def +(other: List[Double]): List[Double] = {
(list zip other).map(t => t._1 + t._2)
}
}
This allowed us to type something like this:
scala> List(1.0, 2.0) + List(3.0, 4.0)
val res0: List[Double] = List(4.0, 6.0)
This is great, but what if I had lists of Floats or Ints or Longs? Do I have to create DoubleOps and FloatOps and IntOps and LongOps classes to make everything consistent? Ack! Boilerplate is Evil!
Let’s use this convenient Numeric[T]
typeclass to get rid of the boilerplate and write our Ops class in a more generic way:
implicit class Ops[T](list: List[T]) {
def +(other: List[T])(implicit ntc: Numeric[T]): List[T] = {
(list zip other).map(t => ntc.plus(t._1, t._2))
}
}
And voila! We suddenly can do our pairwise addition for any lists.
scala> List(2.1, 3.4) + List(4.3, -2.3)
val res1: List[Double] = List(6.4, 1.1)scala> List(2.1f, 3.4f) + List(4.3f, -2.3f)
val res2: List[Float] = List(6.4, 1.1000001)scala> List(21, 34) + List(43, -23)
val res3: List[Int] = List(64, 11)scala> List(21L, 34L) + List(43L, -23L)
val res4: List[Long] = List(64, 11)
If we created a class for complex numbers, all we would have to do is create a class Numeric[Complex]
and our pairwise list addition would work for that as well!
One small refinement: guarding against non-numeric attempts
There’s one relatively minor thing we might want to do to further refine this example. Our Ops
class is going to try to add a “+” to any List[T]
including non-numeric lists like a List[String]
. We can see this by trying to add some lists of strings:
scala> List("one", "two") + List("three", "four")
^
error: could not find implicit value for parameter ntc: Numeric[String] (No implicit Ordering defined for String.)
This is going to fail with a runtime error because we essentially promised there would be an implicit Numeric[T]
in scope when we wrote our implicit parameter. There’s a convenient type restriction syntax we can use for this situation. Instead of just [T]
to represent any type T, we can write [T: Numeric]
that means “any type T for which a Numeric[T]
exists” so that we can guard against adding lists of strings at compile time.
Along with hating boilerplate, Scala programmers hate writing code that could fail at runtime when it’s possible to get the compiler to catch the problem. If you aspire to become a Scala master, you should always try to use the Scala type system and the Scala compiler to mathematically guarantee solid code. (Just like we use Option[T]
instead of allowing the evil null values!)
So if we write our class like this:
implicit class Ops[T: Numeric](list: List[T]) {
def +(other: List[T])(implicit ntc: Numeric[T]): List[T] = {
(list zip other).map(t => ntc.plus(t._1, t._2))
}
}
Then the compiler won’t even let us write something like List("one", "two") + List("three", "four")
.
Guarding against uneven lists
Okay, if we wanted to make a truly bulletproof class, we would also need to guard against a user trying to add lists of uneven size. This has no bearing on type classes or implicits, but just to satisfy readers who are bugged by this oversight, I’ll provide a solution that just copies the extra values if one list is longer than the other…
implicit class Ops[T: Numeric](list: List[T]) {
def +(other: List[T])(implicit ntc: Numeric[T]): List[T] = {
val len = Math.max(list.length, other.length)
val zero = ntc.zero
(list.padTo(len,zero) zip other.padTo(len,zero)).map(t => ntc.plus(t._1, t._2))
}
}
Doing it with Scala 3
Switching our List-adding example to Scala 3 is relatively trivial. We just switch our implicit class (Ops) into an extension method and use the using
keyword instead of implicit
for the implicit parameter, yielding the simple, clean, but still powerful:
extension[T: Numeric] (list: List[T])
def +(other: List[T])(using n: Numeric[T]): List[T] =
(list zip other).map(t => n.plus(t._1, t._2))
If you liked this series of articles
I’m really proud of my first three-part series that I published in 2021 called Scala for Beginners: How to really use Option (and Try and Either) that did a similar exploration through a variety of topics but ultimately led the reader to understand what Monads are. It dovetails nicely with this series because I just showed you how things like Monads get implemented.