Extending Scala Case Class With NoStackTrace Leads To Unexpected toString Behaviour
Say you had an ADT similar to this:
import scala.util.control.NoStackTrace
sealed trait MyError extends NoStackTrace
final case class MyError1(message: String) extends MyError
final case class MyError2(message: String) extends MyError
This might seem weird to some. Why are we extending NoStackTrace
? This allows us to use the MyError
as a return value through something like an Either
:
def sanitiseInput(value: String): Either[MyError, ValidInput]
We can also use it as an error that can be thrown or raised into cats.IO, fs2.Stream, Monix.eval.Task or equivalent:
object IO {
def raiseError[A](t: Throwable): IO[A] //`t` has to extend Throwable if we want to use this function.
//other functions
}
You can also read about some related ideas in Make error ADTs subtypes of Exception.
Now if we use MyError
in a test:
import scala.util.control.NoStackTrace
object MyErrorSuite extends weaver.FunSuite {
sealed trait MyError extends NoStackTrace
final case class MyError1(message: String) extends MyError
final case class MyError2(message: String) extends MyError
test("error message") {
.same(MyError1("error1"), MyError2("error2")) //this is an error
expect}
}
the test output from Weaver-Test gets truncated somewhat:
[info] com.example.validation.extra.MyErrorSuite
[info] - error message 30ms
[info] *************FAILURES**************
[info] com.example.validation.extra.MyErrorSuite
[error] - error message 30ms
[error] Values not equal: (src/test/scala/com/example/validation/extra/MyErrorSuite.scala:12)
[error]
[error] com.example.validation.extra.MyErrorSuite$[MyError1] | com.example.validation.extra.MyErrorSuite$[MyError2]
All we get are the class names returned in the diff:
com.example.validation.extra.MyErrorSuite$[MyError1] | com.example.validation.extra.MyErrorSuite$[MyError2]
The diff we expected was something like:
[MyError1]([error1]) | [MyError2]([error2]) //we can see that the class and error messages are different
When you create a case class it generates a toString
implementation of the form: ClassName(field1Value, field2Value, ....)
.
So why are we loosing our toString
implementation?
Cause
Let’s try a simpler example in the REPL:
case class MyError1(message: String)
> MyError1("Oh noes")
scalaval res35: MyError1 = MyError1(Oh noes) //"Oh noes" is output
We can see that we do get the contents of all fields of the case class written out.
Let’s try extending NoStackTrace
and see if it makes a difference:
import scala.util.control.NoStackTrace
case class MyError2(message: String) extends NoStackTrace
> MyError2("Oh noes")
scalaval res36: MyError2 = MyError2 //no message output
We can see that although the class name is output the contents of the message
field has not.
Interesting. This seems to be the cause of our issue in the test.
It turns out a case class doesn’t generate a toString
method (and other implementations such has hashCode etc) if you already have a custom implementation for that method in a super type.
It’s not a bug, it’s a feature.
So where does our MyError2
class get a custom toString
implementation from?
Lets have a look at the NoStackTrace
class, since MyError2
extends that:
trait NoStackTrace extends Throwable {
override def fillInStackTrace(): Throwable =
if (NoStackTrace.noSuppression) super.fillInStackTrace()
else this
...
}
No toString
implementation here. Let’s follow the inheritance trail to java.lang.Throwable
. Here, we see that it does have a custom toString
implementation:
public String toString() {
String s = getClass().getName();
String message = getLocalizedMessage();
return (message != null) ? (s + ": " + message) : s;
}
From the above implementation we can deduce that for MyError2
the getLocalizedMessage
method returns null
because we only get back the class name s
as output: (MyError2
) as opposed to: MyError2: message
.
Let’s follow along to getLocalizedMessage
to see how message
is calculated:
public String getLocalizedMessage() {
return getMessage();
}
and also to getMessage
:
public String getMessage() {
return detailMessage;
}
The detailMessage
field is set through the many of the constructor methods for Throwable
:
public Throwable(String message) {
fillInStackTrace();
= message; //set
detailMessage }
//or
public Throwable(String message, Throwable cause) {
fillInStackTrace();
= message; //set
detailMessage this.cause = cause;
}
//or
public Throwable(Throwable cause) {
fillInStackTrace();
= (cause==null ? null : cause.toString()); //set
detailMessage this.cause = cause;
}
//or
protected Throwable(String message, Throwable cause,
boolean enableSuppression,
boolean writableStackTrace) {
if (writableStackTrace) {
fillInStackTrace();
} else {
= null;
stackTrace }
= message; //set
detailMessage this.cause = cause;
if (!enableSuppression)
= null;
suppressedExceptions }
Since we have a field named message
and not detailMessage
, we don’t really override the value used by Throwable
to generate its toString
implementation.
Workarounds
If we renamed our message
field in MyError2
to detailMessage
we should be able to get our toString
implementation working:
import scala.util.control.NoStackTrace
case class MyError2(detailMessage: String) extends NoStackTrace
> MyError2("Oh noes")
scalaval res37: MyError2 = MyError2 //Doesn't work
Wow! That didn’t work either. Why though?
If we look at the definition of the detailMessage
field on java.lang.Throwable
we see that it’s private:
private String detailMessage;
This means we can’t override it from a sub class. Boo!
From our previous investigation we can see that all we need to do is override either getLocalizedMessage
or getMessage
or toString
which are all public:
public String toString() {
String s = getClass().getName();
String message = getLocalizedMessage(); //message calculated from here
return (message != null) ? (s + ": " + message) : s;
}
public String getLocalizedMessage() {
return getMessage(); //message content retrieved from here
}
public String getMessage() {
return detailMessage; //message content
}
Override getMessage or getLocalizedMessage
By overriding getMessage
or getLocalizedMessage
in our case class, we can get some form of toString
-ery happening. While this is not ideal, it “works”.
import scala.util.control.NoStackTrace
case class MyError2(override val getMessage: String) extends NoStackTrace
> MyError2("Oh noes")
scalaval res38: MyError2 = MyError2: Oh noes //We did it!
Override toString
If you want a more case classy toString
implementation, you’re going to have to do it yourself:
case class MyError2(message: String) extends NoStackTrace {
override def toString: String = s"MyError2($message)"
}
> MyError2("Oh noes")
scalaval res39: MyError2 = MyError2(Oh noes) //we have case classiness
Now we can get our test to fail with a better error message:
import scala.util.control.NoStackTrace
object MyErrorSuiteTake2 extends weaver.FunSuite {
sealed trait MyError extends NoStackTrace {
val message: String
override def toString: String = {
val className = getClass.getName
s"$className($message)"
}
}
final case class MyError1(message: String) extends MyError
final case class MyError2(message: String) extends MyError
test("error message") {
.same(MyError1("error1"), MyError2("error2"))
expect}
}
Which results in:
info] com.example.validation.extra.MyErrorSuiteTake2
[error] - error message 38ms
[error] Values not equal: (src/test/scala/com/example/validation/extra/MyErrorSuiteTake2.scala:20)
[error]
[error] com.example.validation.extra.MyErrorSuiteTake2$[MyError1]([error1]) | com.example.validation.extra.MyErrorSuiteTake2$[MyError2]([error2])
All this seems a bit tedious… as does extending the Exception
hierarchy. If you do decide to go this route, hopefully this will help you stave off at least one of the issues with extending java.lang.Throwable
and friends.