λ garrity software

Semantic Type Refinement

Types can be great. In the context of Scala, I like using types heavily. They help me think and write better code more quickly. The notion of refined types is a way to leverage types to enforce certain constraints - often at compile time.

Excellent libraries such as refined have an obvious introductory example:

scala> val i2: Int Refined Positive = -5
       error: Predicate failed: (-5 > 0).

To be clear: this is great! I want to talk about another way to refine types without taking away from the standard case.

Another Approach to Refinement

The basic refinement example essentially makes the type more specific. Another way to look at it is that it removes unwanted values from the range of possible values. Rather than a type that accepts integers, we have a type which only accepts positive integers.

This is useful, though in many context I prefer to go further. At the end of the day, with this example, I just have a positive integer. What is the purpose of that integer? Adding specific semantics to a type can make it stronger and more meaningful.

Let's pretend that our positive integer is actually intended to express some configurable maximum concurrency value (please bear with the exception for now):

opaque type MaximumConcurrency = Int

object MaximumConcurrency:

  def apply(candidate: Int): MaximumConcurrency =
    if candidate <= 0 then
      throw new IllegalArgumentException(
        "Maximum concurrency must be 1 or greater."
      )
    else candidate

  given CanEqual[MaximumConcurrency, MaximumConcurrency] = CanEqual.derived

  extension (mc: MaximumConcurrency) def toInt(): Int = mc

end MaximumConcurrency

While this involves slightly more typing, it has several benefits:

  • MaximumConcurrency obviously describes the purpose of the type.
  • The type can be independently documented.
  • Some value of this type can only be equated to other MaximumConcurrency values.
  • Logic and functions can be contextualized to this type.
  • Values of this type cannot exist unless they meet validation criteria.
  • Zero dependencies.

Lest we forget the drawbacks:

  • Value validation does not occur at compile time.
  • Implementation is manual.

Addressing Drawbacks

First off: many values are not known at compile time. Refinement libraries are not unaware of this, and provide tools for refining at runtime.

Manual implementation simply does not bother me -- it is a small effort, and that effort forces me to think about each type and document each type. I am also forced to justify a purpose for each type. I spend more time thinking, and less time actually writing; a common theme with specific types.

That being said, this approach isn't for everything. Sometimes there are literals that just need to be non-semantic, because trying to apply that layer has no value. That's okay, and lines need to be drawn.

What About Exceptions?

There is no reason a type must rely on exceptions to perform validation:

opaque type MaximumConcurrency = Int

object MaximumConcurrency:

  def validate(candidate: Int): Either[MyError, MaximumConcurrency] =
    if candidate <= 0 then Left(MyError.InvalidMaximumConcurrency(candidate))
    else candidate

Refinement of the type can be catered to the case at hand. Use whatever mechanism best fits the type.

Types Without Validation

This same approach can be used to give type restrictions to unconstrained values:

opaque type MaximumConcurrency = Int

object MaximumConcurrency:

  def apply(value: Int): MaximumConcurrency = value

Opaque type aliases are a technique that I use often, as these types still prevent the use of mismatched types and communicate valuable information.

Summary

Restrictions are often power, and clarity is also often power. I enjoy both, and make heavy use of them in my code. In general, I tend to rely on a dependency-free manual approach to refining my types in a way that forces me to justify the existence of every single type - I think the approach is at least worth consideration. I think that libraries which solve a general case well are valuable in their own right and hope that this brief writeup is not taken as a reason to avoid them.

I particularly like the inclusion of opaque types in Scala 3 and use them heavily in lieu of fundamental types.