This evening I decided to learn something about type classes. In the past I would’ve picked a book with good reviews, or read a set of articles written by functional programming masters. This time, I wanted to check if could talk with machines 😜

The basics

Would you describe what type classes are?

Type classes are a concept in functional programming that provide a way to define a set of functions or operations that can be performed on a specific type or set of types.

In a type class, a set of functions or operations are defined, and any type that implements those functions or operations is said to be an instance of that type class. Type classes are used to achieve ad-hoc polymorphism, which means that different functions can be written for different types, but they can all share the same interface or name.

For example, in Haskell, the Eq type class defines the == and /= operators for comparing values of a type. If a type implements these operators, it is an instance of the Eq type class. This allows functions that operate on values of type Eq to be polymorphic and work with any type that is an instance of Eq.

Type classes provide a powerful abstraction mechanism in functional programming and are widely used in languages like Haskell, Scala, and Rust. They allow developers to write generic code that can operate on a wide range of types, while still enforcing constraints on those types to ensure they support the required operations.

Show me an example of type class in Scala.

Sure! Here is an example of a type class in Scala:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
trait Printable[A] {
  def format(value: A): String
}

object PrintableInstances {
  implicit val stringPrintable: Printable[String] =
    new Printable[String] {
      def format(value: String): String = value
    }

  implicit val intPrintable: Printable[Int] =
    new Printable[Int] {
      def format(value: Int): String = value.toString
    }

  implicit val booleanPrintable: Printable[Boolean] =
    new Printable[Boolean] {
      def format(value: Boolean): String = if (value) "yes" else "no"
    }
}

object Printable {
  def format[A](value: A)(implicit printable: Printable[A]): String =
    printable.format(value)

  def print[A](value: A)(implicit printable: Printable[A]): Unit =
    println(format(value))
}

case class Person(name: String, age: Int)

object Person {
  implicit val personPrintable: Printable[Person] =
    new Printable[Person] {
      def format(person: Person): String = s"${person.name} is ${person.age} years old."
    }
}

import PrintableInstances._
import Printable._

val message1: String = format("hello world")
val message2: String = format(42)
val message3: String = format(true)
val message4: String = format(Person("Alice", 30))

print("hello world")
print(42)
print(true)
print(Person("Alice", 30))

In this example, Printable is a type class with a single type parameter A. It defines a format method that takes a value of type A and returns a string representation of it.

The PrintableInstances object contains implicit instances of the Printable type class for various types, including String, Int, Boolean, and Person.

The Printable object provides two methods for working with instances of the Printable type class: format and print. format takes a value of type A and an implicit instance of Printable[A] and returns a string representation of the value. print does the same thing as format, but also prints the result to the console.

Finally, the example demonstrates how to use the Printable type class and its instances to format and print various types of values.

What are the advantages of that design?

There are several advantages of using type classes and this design pattern:

  1. Abstraction: Type classes allow for abstraction over data types. This means that functions can be written in a generic way that works for any data type that satisfies the constraints of the type class.
  2. Reusability: Type classes promote code reuse. Once a type class is defined and instances are created for various data types, the same functionality can be used across different parts of the codebase without duplicating code.
  3. Flexibility: Type classes allow for ad-hoc polymorphism. This means that different data types can be used interchangeably as long as they implement the required functions or operations.
  4. Separation of concerns: Type classes separate concerns by defining operations that are relevant to a specific data type. This helps keep code organized and easier to reason about.
  5. Testability: Type classes make testing easier by allowing for more focused testing of the functionality that is specific to a given type class.

Overall, type classes are a powerful abstraction mechanism that can make code more generic, reusable, flexible, and easier to test.

Deep dive

Ok, show me an example of abstraction.

Sure, here’s an example that demonstrates how type classes can provide abstraction over data types:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
trait Summable[A] {
  def sum(a: A, b: A): A
}

object SummableInstances {
  implicit val intSummable: Summable[Int] =
    new Summable[Int] {
      def sum(a: Int, b: Int): Int = a + b
    }

  implicit val stringSummable: Summable[String] =
    new Summable[String] {
      def sum(a: String, b: String): String = a + b
    }
}

object Calculator {
  def add[A](a: A, b: A)(implicit summable: Summable[A]): A =
    summable.sum(a, b)
}

import SummableInstances._

val sum1: Int = Calculator.add(1, 2)
val sum2: String = Calculator.add("hello", " world")

println(sum1) // prints 3
println(sum2) // prints "hello world"

In this example, Summable is a type class with a single type parameter A. It defines a sum method that takes two values of type A and returns their sum.

The SummableInstances object contains implicit instances of the Summable type class for Int and String.

The Calculator object provides a generic add method that takes two values of type A and an implicit instance of Summable[A] and returns their sum.

Finally, the example demonstrates how the Calculator object can be used to add integers and concatenate strings using the same add method. This demonstrates the abstraction provided by the Summable type class, which allows the same add method to be used with different data types that satisfy the constraints of the type class.

Nice example of abstraction indeed, show me now an example of reusability.

Sure! Here’s an example that shows how type classes can promote code reuse:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
trait Show[A] {
  def show(a: A): String
}

object ShowInstances {
  implicit val intShow: Show[Int] =
    new Show[Int] {
      def show(a: Int): String = a.toString
    }

  implicit val stringShow: Show[String] =
    new Show[String] {
      def show(a: String): String = a
    }
}

object Logger {
  def log[A](a: A)(implicit show: Show[A]): Unit =
    println(show.show(a))
}

import ShowInstances._

val num = 42
val str = "Hello, world!"

Logger.log(num) // prints "42"
Logger.log(str) // prints "Hello, world!"

In this example, Show is a type class with a single type parameter A. It defines a show method that takes a value of type A and returns a string representation of that value.

The ShowInstances object contains implicit instances of the Show type class for Int and String.

The Logger object provides a generic log method that takes a value of type A and an implicit instance of Show[A] and logs the string representation of that value.

Finally, the example demonstrates how the Logger object can be used to log both integers and strings using the same log method. This demonstrates the reusability provided by the Show type class, which allows the same log method to be used with different data types that satisfy the constraints of the type class.

So, why ad-hoc polymorphism is so flexible?

Ad-hoc polymorphism is flexible because it allows for the implementation of the same functionality across different data types without the need for inheritance or a common interface.

In traditional polymorphism, the behavior of a method or function is determined by the runtime type of the object or value being passed in. This means that the behavior is static and cannot be changed without modifying the source code.

Ad-hoc polymorphism, on the other hand, allows the behavior of a method or function to be determined by the implementation of a type class for a particular data type. This means that the behavior can be customized for different data types without changing the source code of the method or function.

This flexibility is particularly useful in situations where different data types have different requirements or semantics for a given operation or behavior. With ad-hoc polymorphism, the behavior can be customized for each data type separately without resorting to complex inheritance hierarchies or interfaces.

Overall, ad-hoc polymorphism provides a powerful mechanism for achieving flexibility and extensibility in code. It allows for a high degree of customization and can help simplify code by removing the need for complex hierarchies or interfaces.

Could you show example of separation of concerns with type classes?

Sure! Here’s an example that shows how type classes can be used to separate concerns in a simple web application:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
case class User(id: Int, name: String)

trait UserRepository {
  def find(id: Int): Option[User]
}

class InMemoryUserRepository extends UserRepository {
  private val users = Map(1 -> User(1, "Alice"), 2 -> User(2, "Bob"))

  def find(id: Int): Option[User] = users.get(id)
}

trait UserJsonConverter {
  def toJson(user: User): String
}

class UserJsonConverterImpl extends UserJsonConverter {
  def toJson(user: User): String = s"""{"id": ${user.id}, "name": "${user.name}"}"""
}

class UserController(userRepository: UserRepository, jsonConverter: UserJsonConverter) {
  def findUser(id: Int): String = {
    userRepository.find(id) match {
      case Some(user) => jsonConverter.toJson(user)
      case None => s"""{"error": "User with ID $id not found"}"""
    }
  }
}

object ExampleApp extends App {
  val userRepository = new InMemoryUserRepository()
  val jsonConverter = new UserJsonConverterImpl()
  val userController = new UserController(userRepository, jsonConverter)

  println(userController.findUser(1)) // prints {"id": 1, "name": "Alice"}
  println(userController.findUser(3)) // prints {"error": "User with ID 3 not found"}
}

In this example, we have separated the concerns of data storage, JSON serialization, and web application logic using type classes.

The UserRepository trait defines an interface for accessing user data, with a concrete implementation provided by InMemoryUserRepository.

The UserJsonConverter trait defines an interface for converting User objects to JSON strings, with a concrete implementation provided by UserJsonConverterImpl.

The UserController class defines a web application endpoint for finding user data. It takes instances of UserRepository and UserJsonConverter as dependencies and uses them to find the requested user and convert it to a JSON string.

By separating concerns in this way, we can change the implementation of UserRepository or UserJsonConverter without affecting the implementation of UserController. This makes our code more modular and easier to maintain.

Nice, why it’s easier to test type classes?

Type classes can be easier to test than traditional inheritance hierarchies or interfaces because they promote a separation of concerns and can reduce coupling between different parts of the code.

With traditional polymorphism, the behavior of a method or function is determined by the runtime type of the object or value being passed in. This can make it difficult to write unit tests for the method or function because you may need to create test objects that implement the same interface as the production code.

With type classes, on the other hand, the behavior of a method or function is determined by the implementation of a type class for a particular data type. This means that you can write unit tests for the type class separately from the production code, using mock objects or test doubles to simulate the different data types that the code will be operating on.

For example, in the web application example I showed earlier, we could write a unit test for UserJsonConverter by creating a mock User object and verifying that the toJson method returns the expected JSON string. We could then write a separate unit test for UserController that verifies that it calls the find method on the UserRepository with the expected user ID and passes the resulting user object to the toJson method of the UserJsonConverter.

Overall, type classes can make it easier to write testable code by promoting a separation of concerns and reducing coupling between different parts of the code.

Could you show an example of type classes test using Scalatest library?

Sure! Here’s an example of how you could use the Scalatest library to test the UserJsonConverter type class from the web application example I showed earlier:

1
2
3
4
5
6
7
8
9
10
11
import org.scalatest._

class UserJsonConverterSpec extends FlatSpec with Matchers {
  "UserJsonConverter" should "convert a User object to a JSON string" in {
    val user = User(1, "Alice")
    val converter = new UserJsonConverterImpl()
    val expectedJson = """{"id": 1, "name": "Alice"}"""

    converter.toJson(user) shouldBe expectedJson
  }
}

In this test, we create a User object and a UserJsonConverterImpl object, and then call the toJson method of the converter object with the user object as an argument. We then use the Scalatest shouldBe matcher to verify that the result is equal to the expected JSON string.

This test verifies that the UserJsonConverter type class is able to correctly serialize User objects to JSON strings, without needing to create a mock User object or modify the production code in any way.

You could write similar tests for the UserRepository and UserController components of the web application, using test doubles or mock objects to simulate different data types or dependencies.

The challenge

Hmm, I don’t really like the UserJsonConverterImpl implementation, it’s so manual. It doesn’t scale if my application has hundreds of domain classes. How to derive converters in a more automated way?

You’re right, manually implementing converters for each domain class can become cumbersome and error-prone as the number of classes grows. One way to derive converters in a more automated way is to use a code generation tool, such as ScalaPB or Circe.

ScalaPB is a protocol buffer compiler plugin for Scala that generates case classes and corresponding JSON converters from .proto files. Circe is a JSON library for Scala that provides automatic derivation of JSON codecs (encoders and decoders) for case classes using macros.

Here’s an example of how you could use Circe to automatically derive a JSON encoder and decoder for the User case class:

1
2
3
4
5
6
7
8
9
import io.circe._
import io.circe.generic.semiauto._

case class User(id: Int, name: String)

object UserJson {
  implicit val userEncoder: Encoder[User] = deriveEncoder[User]
  implicit val userDecoder: Decoder[User] = deriveDecoder[User]
}

In this example, we define the User case class and a companion object UserJson that contains two implicit values for encoding and decoding User objects to and from JSON, respectively.

The deriveEncoder and deriveDecoder methods are macros that generate the necessary code for encoding and decoding the User case class, based on its fields and types. This means that we don’t need to write any boilerplate code to convert User objects to and from JSON.

To use the UserJson codecs, we can simply import them into other parts of the code:

1
2
3
4
5
import UserJson._

val user = User(1, "Alice")
val json = userEncoder(user).noSpaces
val decodedUser = decode[User](json)

This example shows how type classes and code generation tools can work together to provide flexible and automated serialization and deserialization of domain objects.

How to test that a derived encoder is able to encode different values for the id and name fields of the User case class?

One way to test that the derived encoder is able to handle a variety of values for the id and name fields of the User case class is to write a test that generates random User objects and encodes them to JSON, then decodes the JSON and checks that the resulting object is equal to the original.

Here’s an example of how you could write such a test using the Scalacheck library:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.scalatest._
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import io.circe.syntax._
import io.circe.parser._

class UserJsonSpec extends FlatSpec with Matchers with ScalaCheckPropertyChecks {
  "UserJson" should "encode and decode random User objects" in {
    forAll { (id: Int, name: String) =>
      val user = User(id, name)
      val json = user.asJson.noSpaces
      val decodedUser = decode[User](json)

      decodedUser shouldBe Right(user)
    }
  }
}

In this test, we use the Scalacheck forAll method to generate random Int and String values for the id and name fields of the User case class, respectively. We then create a User object with those values, encode it to JSON using the asJson method from Circe, decode the resulting JSON using the decode method from Circe, and finally check that the decoded object is equal to the original object using the Scalatest shouldBe matcher.

This test verifies that the derived encoder and decoder are able to correctly handle a wide range of values for the id and name fields of the User case class, without needing to manually specify test cases for each possible value.

Thank you for the nice evening.

You’re welcome! It was a pleasure helping you. Have a good evening!

Summary

  • I needed to know what to ask
  • I’ve got personalized article about type classes, driven by my needs
  • Code examples were easy to understand and they offered variety of use-cases
  • I’ve got a few libraries recommendation like Circe or Scalacheck, even if I didn’t ask explicitly
  • Chat suggested mock for User case class, let me disagree with that

I’m going to repeat such chats occasionally, and warmly encourage you to try it also. Let me know if you find any mistakes in ChatGPT responses 😀

Updated:

Comments