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 parameterA
. It defines a format method that takes a value of typeA
and returns a string representation of it.The PrintableInstances object contains implicit instances of the
Printable
type class for various types, includingString
,Int
,Boolean
, andPerson
.The
Printable
object provides two methods for working with instances of thePrintable
type class:format
andformat
takes a value of typeA
and an implicit instance ofPrintable[A]
and returns a string representation of the value.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:
- 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.
- 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.
- 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.
- 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.
- 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 parameterA
. 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 forInt
andString
.The
Calculator
object provides a generic add method that takes two values of typeA
and an implicit instance ofSummable[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 theSummable
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 parameterA
. It defines ashow
method that takes a value of typeA
and returns a string representation of that value.The
ShowInstances
object contains implicit instances of theShow
type class forInt
andString
.The
Logger
object provides a generic log method that takes a value of type A and an implicit instance ofShow[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 theShow
type class, which allows the samelog
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 convertingUser
objects to JSON strings, with a concrete implementation provided byUserJsonConverterImpl
.The
UserController
class defines a web application endpoint for finding user data. It takes instances ofUserRepository
andUserJsonConverter
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
orUserJsonConverter
without affecting the implementation ofUserController
. 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 mockUser
object and verifying that thetoJson
method returns the expected JSON string. We could then write a separate unit test forUserController
that verifies that it calls the find method on theUserRepository
with the expected user ID and passes the resulting user object to thetoJson
method of theUserJsonConverter
.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 aUserJsonConverterImpl
object, and then call thetoJson
method of the converter object with the user object as an argument. We then use the ScalatestshouldBe
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 serializeUser
objects to JSON strings, without needing to create a mockUser
object or modify the production code in any way.You could write similar tests for the
UserRepository
andUserController
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 objectUserJson
that contains two implicit values for encoding and decoding User objects to and from JSON, respectively.The
deriveEncoder
andderiveDecoder
methods are macros that generate the necessary code for encoding and decoding theUser
case class, based on its fields and types. This means that we don’t need to write any boilerplate code to convertUser
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
andname
fields of theUser
case class is to write a test that generates randomUser
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 randomInt
andString
values for theid
andname
fields of theUser
case class, respectively. We then create aUser
object with those values, encode it to JSON using theasJson
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 ScalatestshouldBe
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 😀
Comments