The case for case objects in Scala

by way of Settlers of Catan

Steve Layland
7 min readAug 29, 2024
This is what Dall-E thinks Settlers of Catan looks like. And people are worried AI is taking over…

Have you ever wondered what a case object actually is in Scala? Yeah, me neither! But then someone asked me the following question on LinkedIn:

What is the difference between a case object and a regular object?

… and I realized I didn’t really know! My knee jerk response was that case objects are just singletons used as case patterns in a match statement.

@ case object Sheep
defined object Sheep

@ val s = Sheep
f: Sheep.type = Sheep

@ s match { case Sheep => "got a Sheep" }
res3: String = "got a Sheep"

While true, this answer seems insufficient. To most Scala newcomers the idea of case objects and case classes seem exotic. Like we know they’re special somehow, we’re just not sure why.

So what really is a case object ?? Do they do anything useful? For that matter, what is so interesting about case classes that they deserve their own concept and syntax?

Let’s find out!

Case Classes

Before we get to case objects, let’s quickly review the concept of a case class.

The official Scala docs have a great introduction to case classes, but this succinct definition really sums up their importance:

Case classes are used to model immutable data structures.

Boom. Read that again.

The sky’s the limit with what case classes allow you to model! As an example, zoom in on this image to see parts of an expressive, type safe model for the game Settlers of Catan.

Screenshot of code editor showing excerpt of a type model for the popular game Settlers of Catan, using many case object and case classes in the design.
Nobody wants your frickin’ sheep!

This is an incredibly powerful concept, so you can understand why case classes are used all the time in Scala. As such, they contain several user-friendly features that add up to significant time savings in the long run:

  1. You don’t need to type new to instantiate a class (apply is the constructor)
  2. Since they are immutable, they have a copy method to easily create new instances
  3. They have built in equals method so that case classes with the same data are considered equal
  4. They have a default unapply method useful in pattern matching
  5. They have a default toString method useful for debugging
  6. They are Serializable for machine-to-machine communication

(There are some other tidbits from the Product and Equals traits thrown in here as well, like productArity, and canEqual etc, but the above are the more noticable features)

Let’s create a simple Sheep model to highlight each of these with toy examples

@ case class Sheep(name: String, favoriteThing: String)
defined class Sheep

// create a new instance without `new`
@ val shaun = Sheep("Shaun", "eating wheat!")
sean: Sheep(name = "Shaun", favoriteThing = "eating wheat!")

// easily create a new immutable object with `copy`
@ shaun.copy(name = "Shorn")
res13: Sheep(name = "Shorn", favoriteThing = "eating wheat!")

// treat the same data as equal (this is not how normal classes behave)
@ shaun == Sheep("Shaun", "eating wheat!")
res14: Boolean = true

// `unapply` for easily matching inside the case class
@ shaun match { case Sheep(_, fav) => fav.toUpperCase }
res15: String = "EATING WHEAT!"

// prettified `toString` for debugging
@ s"my sheep is $shaun"
res16: String = "my sheep is Sheep(Shaun,eating wheat!)"

// we'll cover serialization more below

This may not look like much, but on the whole it makes it almost trivial to model out complex domains and then focus on actually wiring up the application logic.

On the other hand, here’s what it looks like if you wanted to build a case class yourself with all the same functionality:

class Sheep(val name: String, val favoriteThing: String) extends Product2[String,String] with Serializable {
def _1: String = name
def _2: String = favoriteThing
def canEqual(o: Any): Boolean = o match {
case _: Sheep => true
case _ => false
}
def copy(name: String = this.name, favoriteThing: String = this.favoriteThing) = new Sheep(name, favoriteThing)
override def toString(): String = s"Sheep($name,$favoriteThing)"
override def equals(o: Any): Boolean = o match {
case Sheep(n,f) => name == n && favoriteThing == f
case _ => false
}
}
object Sheep {
def apply(name: String, favoriteThing: String) = new Sheep(name, favoriteThing)
def unapply(sheep: Sheep): Option[Tuple2[String,String]] = Some(name, favoriteThing)
}

Sure, it’s doable, but who’s going to do that when they can do this?!

case class Sheep(name: String, favoriteThing: String)

Now that you’re all caught up on case classes and how they’re used, let’s get into case objects. Spoiler alert: they’re basically the just singleton version of case classes!

Case Objects

If case classes are used to model immutable data structures, case objects are used to model singleton ideas, where the singleton itself is the data. The canonical example is you want to model the commands you might send to a remote app:

sealed trait Action
case object Roll extends Action
case AcceptTrade(from: Player) extends Action

In this case (no pun intended), the Roll command doesn’t need any extra arguments so it also doesn’t need any fancy apply , copy or unapply methods — there are no arguments to manipulate. Additionally, there’s no need for equals because singletons can only be defined once.

Clearly case objects, like their case class cousins, are powerful tools to model complex domains, but do we really need a special syntax for this? The scala docs state that case objects are somehow “more powerful” than normal objects, particularly when pattern matching.

However, we can pattern match with normal singleton objects as well:

@ object Wheat
defined object Wheat

@val w = Wheat
w: Wheat.type = ammonite.$sess.cmd1$Wheat$@14ef2482

@ w match { case Wheat => "got a Wheat"
res18: String = "got a Wheat"

So, what are these magical features that make case objects so special?

Basically, since apply, unapply, copy and equals aren’t applicable for singletons, only two potential differences remain:

  1. They have an improved toString implementation
  2. They’re serializable

Improved toString

You have already seen this above, but similar to case classes vs normal classes, the string representation of a case object is much cleaner and easier to read in stack traces and debug logs.

@ object Brick
defined object Brick

@ case object Rock
defined Rock

@ s"hey, i'm a $Brick"
res21: String = "hey, i'm a ammonite.$sess.cmd94$Brick$@4e79d1fd"

@ s"hey, i'm a $Rock"
res22: String = "hey, i'm a Rock"

Note: Brick.toString would look something like Brick$@4e70d1fd when run outside of Ammonite.

Serialization

As you’re likely aware, Serialization allows you to turn the in-memory representation of computer code into something that can be easily stored to disk or transferred to another machine. Being able to serialize code is a critical feature when sending messages (think Akka) or compiled programs (think Apache Spark) to and from other machines.

There are other serialization libraries like Kryo or Protocol Buffers, but as a builtin convenience Scala uses Java’s serialization under the hood:

@ import java.io._
import java.io._

@ def serialize(obj:AnyRef):Seq[Byte] = {
val buff = new ByteArrayOutputStream()
val outStream = new ObjectOutputStream(buff)
outStream.writeObject(obj)
outStream.close()
buff.toByteArray
}
defined function serialize

Starting in Scala 3 normal objects are serializable by default, but in scala 2.13 and earlier this wasn’t the case.

In scala 2.13, if you tried to serialize our case object Rock and object Brick singletons from above, you’d see that you can serialize Rocks but not Bricks:

@ serialize(Rock)
res26: Seq[Byte] = ArraySeq(
-84,
-19,
0,
5,
115,
114,
0,
38,
115,
99,
97,
108,
97,
46,
114,
117,
110,
116,
...

@ serialize(Brick)
java.io.NotSerializableException: ammonite.$sess.cmd1$Bar$
java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1175)
java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:345)
ammonite.$sess.cmd71$.serialize(cmd71.sc:4)
ammonite.$sess.cmd72$.<clinit>(cmd72.sc:1)

To see why this might be a problem, let’s return to our commands for things to do in Catan and add a non-serializable EndTurn action:

sealed trait Action
case object Roll extends Action
case AcceptTrade(from: Player) extends Action
object EndTurn extends Action

If you’re not careful when sending these commands over the network to your Catan server, you may run into a NotSerializableException crashing at runtime whenever an EndTurn action is sent to the server.

You can fix this with

sealed trait Action extends Serializable

or

object EndTurn extends Action with Serializable

Or just use case object from the start, keeping things clean and worry-free:

sealed trait Action
case object Roll extends Action
case AcceptTrade(from: Player) extends Action
case object EndTurn extends Action

Again, since objects are now serializable in Scala 3, this isn’t really an issue any more.

Bringing it home

Now that you understand all the subtle nuances between case objects, case classes and their ‘normal’ brothers, here’s an unexpected turn:

Under the hood, case objects are basically just case classes anyway!

Seriously, you can see this when looking at the typer output of scalac when you compile a simple foo.scala file like this:

case object CaseObject

case class CaseClass()

Running scalac -Xprint:typer foo.scala will show that CaseObject is turned into a zero-argument case class, so it inherits all the same goodies that case class does from Product and Serializable:

  final lazy module case val CaseObject: CaseObject = new CaseObject()
final module case class CaseObject() extends Object(), _root_.scala.Product,
_root_.scala.Serializable { this: CaseObject.type =>}

Similarly, the CaseClass will show the extra copy method, and the apply , unapply, and toString convenience methods that we talked about earlier.

  case class CaseClass() extends Object(), _root_.scala.Product, _root_.scala.Serializable
{
def copy(): CaseClass = new CaseClass()
}

final lazy module val CaseClass: CaseClass = new CaseClass()
final module class CaseClass() extends AnyRef() { this: CaseClass.type =>
def apply(): CaseClass = new CaseClass()
def unapply(x$1: CaseClass): true.type = true
override def toString: String = "CaseClass"
}

Conclusion

  • Case objects are basically zero argument case classes used for modeling complex domains.
  • Unlike normal objects, case objects are serializable and have a pretty toString implementation. Since normal objects are now serializable in Scala 3, the only real difference you’re likely to see is the default toString implementation in case objects.
  • Both case objects and case classes are essential to why Scala is a flexible and expressive language for modeling complex domains.

And that’s it! Now you know more than you’ve ever wanted to about a small yet important part of the Scala language.

--

--