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.