Type Gymnastics with Builders - Part 3 - Errors Are UX Too

Views
Article hero image

Now that we’ve got a good understanding about the limitations of the basic builder pattern, let’s improve our error messages.

We’ve already come a long way, so let’s take a moment to review how our error messages have evolved.

Guest post by Morgen Peschke

Java-style Builders

These typically produce an error on the first missing field, which can look like this:

java.util.NoSuchElementException: Missing Host
	at rs$line$1$.<clinit>(rs$line$1:1)
	at rs$line$1.res0(rs$line$1)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Delega

While this is helpful, and certainly better than nothing, it does mean that multiple missing fields have to be solved iteratively, and it is unfortunate that it’s a runtime error.

Phantom Type Builders

Scala can improve by using Phantom types to fail at compile time, and report all of the fields which are present:

Cannot prove that builder.AppBuilder.Empty with builder.AppBuilder.WithHealthChecks with builder.AppBuilder.WithUsers with builder.AppBuilder.WithHost with builder.AppBuilder.WithBooks with builder.AppBuilder.WithMaxConnections =:= builder.AppBuilder.Complete.
    .build

This is a big improvement, however because it doesn’t list all the fields, it’s not particularly user friendly.

Basic Builders

The builders in our first segment improve on builders using Phantom Types because they track all of the fields, so they’ll be present in the error message:

value build is not a member of builder.AppBuilder[[+A]cats.effect.IO[A],Some[String],None.type,Some[Int],None.type,Some[models.Users[[+A]cats.effect.IO[A]]],Some[models.Books[[+A]cats.effect.IO[A]]],Some[cats.data.NonEmptyChain[models.HealthCheck[[+A]cats.effect.IO[A]]]]]
possible cause: maybe a semicolon is missing before `value build`?
    .build

This is both good and bad, as it means that smaller builders will have a full list that can be quickly checked for the missing field - but larger builders provide so much information that it can be overwhelming.

Do you have a moment to talk about Typeclasses?

Interestingly enough, Scala provides some very helpful quality of life around typeclasses that we can use to provide clear and complete error messages for incomplete builders.

Typeclasses are typically used to encode constraints or capabilities, and in this case we’ll be adding a typeclass which encodes the capability to build our AppConfig from an AppBuilder, and the constraint that only very specific shapes of AppBuilder are allowed to do this.

We’ll name this typeclass IsReady and put it in the companion object of AppBuilder:

@scala.annotation.implicitNotFound(
    """Unable to build an AppConfig[A], one or more fields are missing
  host: ${H}
  port: ${P}
  maxConnections: ${MC}
  connectionTimeout: ${CT}
  users: ${U}
  books: ${B}
  healthChecks: ${HC}
""")
sealed trait IsReady[
    F[_],
    H <: Option[String],
    P <: Option[Int],
    MC <: Option[Int],
    CT <: Option[Int],
    U <: Option[Users[F]],
    B <: Option[Books[F]],
    HC <: Option[NonEmptyChain[HealthCheck[F]]]
  ] {
    def build(builder: AppBuilder[F, H, P, MC, CT, U, B, HC]): AppConfig[F]
  }
  object IsReady {
    implicit def allFieldsPresent[F[_]]: IsReady[
      F,
      Some[String],
      Some[Int],
      Some[Int],
      Some[Int],
      Some[Users[F]],
      Some[Books[F]],
      Some[NonEmptyChain[HealthCheck[F]]]
    ] = new IsReady[
      F,
      Some[String],
      Some[Int],
      Some[Int],
      Some[Int],
      Some[Users[F]],
      Some[Books[F]],
      Some[NonEmptyChain[HealthCheck[F]]]
    ] {
      override def build(builder: AppBuilder[F, Some[String], Some[Int], Some[Int], Some[Int], Some[Users[F]], Some[Books[F]], Some[NonEmptyChain[HealthCheck[F]]]]): AppConfig[F] =
        AppConfig[F](
          host = builder.hostOpt.value,
          port = builder.portOpt.value,
          maxConnections = builder.maxConnectionsOpt.value,
          connectionTimeout = builder.connectionTimeoutOpt.value,
          users = builder.usersOpt.value,
          books = builder.booksOpt.value,
          healthChecks = builder.healthChecksOpt.value.toNonEmptyList,
        )
    }
  }

Once IsReady exists, we’ll remove the extension class that provides the build method, and reimplement it as a method on AppBuilder:

  def build(implicit isReady: AppBuilder.IsReady[F, H, P, MC, CT, U, B, HC]): AppConfig[F] =
    isReady.build(this)

Making IsReady a sealed trait and only providing an instance when all fields are Some[_] maintains our type safety and prevents circumventing this check.

Now our error message looks like this, and it becomes trivial to figure out which fields are missing:

Unable to build an AppConfig[A], one or more fields are missing
  host: Some[String]
  port: None.type
  maxConnections: Some[Int]
  connectionTimeout: None.type
  users: Some[models.Users[[+A]cats.effect.IO[A]]]
  books: Some[models.Books[[+A]cats.effect.IO[A]]]
  healthChecks: Some[cats.data.NonEmptyChain[models.HealthCheck[[+A]cats.effect.IO[A]]]]
     [9:3]

The key here is the @implicitNotFound annotation, which allows customizing the error message when the compiler can’t summon an implicit.

@implicitNotFound has a single String argument, and this is both a constant string, and also isn’t. The docs go into more detail, but the TL;DR is that it has to be a compile-time constant (so .stripMargin is unavailable) but the string itself is used as a template and the types can be interpolated to provide the helpful error message above.

Now that our error messages are a bit cleaner, next time we’ll start making some quality of life improvements in the AppBuilder API.

scala builder types series