Skip to main content

Always make 'case class' final

final case class

When you use case class, make sure you make it final.

final case class Foo(n: Int) // 👍
case class Foo(n: Int) // 🚫

Why final case class?

Why? Extending case class can cause unwanted behaviours. You may say case class can't extend another case class so why do we need to worry about it?

case class WithNumber(num: Int)
case class Item(num: Int, name: String) extends WithNumber(num)
// case class Item has case ancestor WithNumber, but case-to-case inheritance is prohibited.
// To overcome this limitation, use extractors to pattern match on non-leaf nodes.
// case class Item(num: Int, name: String) extends WithNumber(num)
// ^
// Compilation Failed

case class can't extend another case class, but a regular class can. What's wrong with it? Let's have a look at this example.

case class WithNumber(num: Int)
class Item(num: Int, name: String) extends WithNumber(num)
// No more compile-time error

Now the problem is

equals

new Item(1, "A") == new Item(1, "A") // true, as expected
new Item(1, "ABC") == new Item(1, "XYZ") // true, what?! 😨

As you may know, this is because Item.equals is actually calling super.equals as it is a regular class meaning that it doesn't have its ownequals. The super class of Item is WithNumber so that == is the same as WithNumber(1) == WithNumber(1).

hashCode

There is another problem with hashCode as well.

new Item(1, "ABC").## == new Item(1, "XYZ").## // true
new Item(1, "ABC").##
// Int = -1345110089
new Item(1, "XYZ").##
// Int = -1345110089
// or
new Item(1, "ABC").hashCode
// Int = -1345110089
new Item(1, "XYZ").hashCode
// Int = -1345110089

This can be a big problem when it's used with hash-based data structure.

case class WithNumber(num: Int)
class Item(num: Int, name: String, price: BigDecimal) extends WithNumber(num)

Set(new Item(1, "ABC", 123), new Item(1, "DEF", 456), new Item(1, "XYZ", 999))
// Set[Item] = Set(WithNumber(1))

Map(new Item(1, "ABC", 123) -> 1, new Item(1, "DEF", 456) -> 2, new Item(1, "XYZ", 999) -> 3)
// Map[Item, Int] = Map(WithNumber(1) -> 3)

toString

And of course, toString doesn't show what you may expect but only what case class shows.

new Item(1, "ABC").toString
// WithNumber(1)

Pattern Matching

Not to mention the broken pattern matching.

new Item(1, "ABC") match {
case Item(num, name) => println(s"num: $num / name: $name")
}

// not found: value Item
// case Item(num, name) => println(s"num: $num / name: $name")
// ^
// Compilation Failed

Using the super case class doesn't work either.

new Item(1, "ABC") match {
case WithNumber(num) => println(s"num: $num")
}
// constructor cannot be instantiated to expected type;
// found : WithNumber
// required: Item
// case WithNumber(num) => println(s"num: $num")
// ^
// Compilation Failed

So it's always good to make case class final

final case class Item(num: Int, name: String)

Any Exceptional Cases?

newtype

There might be some exceptional cases. One of them is when using newtype library. Since there won't be an actual case class for newtype, you don't need to make @newtype case class final. So the following one is fine.

@newtype case class Id(value: Int)

Just case object

If you're wondering if case object should also be final, no it should not because you cannot extend object so case object is already final.