Type Gymnastics with Builders - Part 5 - Order, Order, I Say!

Views
Article hero image

In the previous post in our series on builders, we added some considerable flexibility to our builder’s API.

Guest post by Morgen Peschke

The basic builder pattern we’ve built on so for as been focused on keeping track of what needs to be done, but it doesn’t have any opinions on the order of when things happen, which is generally good, but occasionally we need to be a bit more opinionated.

We’re going to add some (admittedly arbitrary) constraints:

  1. host and port must be configured first, though the order in which they are configured does not matter.
  2. maxConnections and connectionTimeoutSeconds must be configured before the algebras, again the order in which they are configured does not matter.
  3. HealthChecks must be configured last

Controlling Order With Parameters

Similar to how build was restricted to only be accessible after all fields were present in the initial builder implementation, we can adjust the parameters of the other extension methods to add constraints.

For host and port, this is fairly straightforward - just set every other parameter to None.type:

  implicit final class AppBuilderHostOps[
    F[_],
    P <: Option[Int]
  ](private val builder: AppBuilder[F, None.type, P, None.type, None.type, None.type, None.type, None.type]) extends AnyVal {
    def host(h: String): AppBuilder[F, Some[String], P, None.type, None.type, None.type, None.type, None.type] = builder.withHost(h)
  }

  implicit final class AppBuilderPortOps[
    F[_],
    H <: Option[String]
  ](private val builder: AppBuilder[F, H, None.type, None.type, None.type, None.type, None.type, None.type]) extends AnyVal {
    def port(p: Int): AppBuilder[F, H, Some[Int], None.type, None.type, None.type, None.type, None.type] = builder.withPort(p)
  }

Note that the P and H parameters allow host and port to be called in any order relative to each other.

maxConnections and connectionTimeoutSeconds are similar:

  implicit final class AppBuilderMaxConnOps[
    F[_],
    H <: Option[String],
    P <: Option[Int],
    CT <: Option[Int],
    HC <: Option[NonEmptyChain[HealthCheck[F]]]
  ](private val builder: AppBuilder[F, H, P, None.type, CT, None.type, None.type, HC]) extends AnyVal {
    def maxConnections(m: Int): AppBuilder[F, H, P, Some[Int], CT, None.type, None.type, HC] = builder.withMaxConnections(m)
  }

  implicit final class AppBuilderConnTimeoutOps[
    F[_],
    H <: Option[String],
    P <: Option[Int],
    MC <: Option[Int],
    HC <: Option[NonEmptyChain[HealthCheck[F]]]
  ](private val builder: AppBuilder[F, H, P, MC, None.type, None.type, None.type, HC]) extends AnyVal {
    def connectionTimeoutSeconds(ct: Int): AppBuilder[F, H, P, MC, Some[Int], None.type, None.type, HC] = builder.withConnectionTimeout(ct)
  }

We don’t constrain them to be called after host and port, since that’s already taken care of, and should help avoid duplicate errors if they get out of order.

The constraints for the extension methods for books and users are unchanged, for the same reason.

The constraints for healthChecks are the inverse of host and port:

  implicit final class AppBuilderHealthChecksOps[
    F[_],
    HC <: Option[NonEmptyChain[HealthCheck[F]]]
  ](private val builder: AppBuilder[F, Some[String], Some[Int], Some[Int], Some[Int], Some[Users[F]], Some[Books[F]], HC]) extends AnyVal {
    def healthCheck(hc: HealthCheck[F]): AppBuilder[F, Some[String], Some[Int], Some[Int], Some[Int], Some[Users[F]], Some[Books[F]], Some[NonEmptyChain[HealthCheck[F]]]] =
      builder.withHealthChecks(NonEmptyChain.one(hc))

    def healthChecks(hc: NonEmptyList[HealthCheck[F]]): AppBuilder[F, Some[String], Some[Int], Some[Int], Some[Int], Some[Users[F]], Some[Books[F]], Some[NonEmptyChain[HealthCheck[F]]]] =
      builder.withHealthChecks(NonEmptyChain.fromNonEmptyList(hc))
  }

Controlling Order With Typeclasses

If the similarity with build in the previous section made you think, “they’re going to talk about typeclasses again,” congratulations!

Your prize is: getting to hear more about typeclasses 🎉

The transformation is similar to what we did for build, first introducing a typeclass to encode the constraint, then moving the implementation to a method on the class which delegates to the typeclass:

  @scala.annotation.implicitNotFound(
    """AppConfig[+A].host can only be set if all of these fields have not been set:
  host: ${H}
  maxConnections: ${MC}
  connectionTimeout: ${CT}
  users: ${U}
  books: ${B}
  healthChecks: ${HC}
""")
  sealed trait CanSetHost[
    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 host(h: String, builder: AppBuilder[F, H, P, MC, CT, U, B, HC])
    : AppBuilder[F, Some[String], P, MC, CT, U, B, HC]
  }
  object CanSetHost {
    implicit def instance[F[_], P <: Option[Int]]: CanSetHost[F, None.type, P, None.type, None.type, None.type, None.type, None.type] =
      new CanSetHost[F, None.type, P, None.type, None.type, None.type, None.type, None.type] {
        override def host(h: String, builder: AppBuilder[F, None.type, P, None.type, None.type, None.type, None.type, None.type]): AppBuilder[F, Some[String], P, None.type, None.type, None.type, None.type, None.type] =
          builder.withHost(h)
      }
  }
  def host(value: String)(implicit canSetHost: AppBuilder.CanSetHost[F, H, P, MC, CT, U, B, HC]): AppBuilder[F, Some[String], P, MC, CT, U, B, HC] =
    canSetHost.host(value, this)

The only differences are the error message and the way that instance is defined.

The compile errors this produces are quite nice:

AppConfig[+A].host can only be set if all of these fields have not been set:
  host: None.type
  maxConnections: Some[Int]
  connectionTimeout: Some[Int]
  users: Some[models.Users[[+A]cats.effect.IO[A]]]
  books: None.type
  healthChecks: None.type
 [12:3]

The boilerplate this requires is substantial, so we’ll leave converting the rest of the fields as an exercise for the reader.

Next time, we’ll look to see if we can make our builder a bit smarter and offload handling some intermediate dependencies.

scala builder types series