Iron updates: turning opaque types into value objects

7 minute read

iron

Disclaimer. It is not a full-featured introduction to Iron library.

The note highlights a couple of updates that seem very important to me. The updates are not released yet, and there is even one unassigned “first good issue” ticket. It could be a good thing to tackle for somebody.

Status quo

I need to write down some words for those of us who, like me, occasionally write in Scala 3 and look for the upgrade with curiosity.

The context for the note is co-allocations of the facts:

  • Scala 3 doesn’t support Scala 2 macro, macros-based features of scala-newtype and refined libraries are not working. It definitely hurt people who used to have everything neat with definitions like this:
    @newtype case class FirstName(value: NonEmptyString)
    
  • opaque types were introduced. Initially, they were perceived (by some people, including me) as built-in tooling to have a native mechanism for Value Objects (in DDD terms). In practice, additional manipulations are required; opaque types are not equivalent to newtypes. Specifically, extra tricks are needed to enable standard apply and value functions. trading/Newtype.scala is an example of how to turn on value objects back.

In case you aspire to acquire a richer understanding of the prior status quo and problems, I recommend reading the discussion: Improve opaque types (scala-lang.org). Gabriel Volpe triggered newtype-specific discussion.

And more detailed motivation can be found in the original SIP-35 - OPAQUE TYPES.

Iron

Brief intro

I recommend quickly going through the README/docs of Iltotore/iron library.

Gabriel also has a short demo under the Trading project: IronDemo.scala:

type AgeR = DescribedAs[
  Greater[0] & Less[151],
  "Alien's age must be an integer between 1 and 150"
]

Long story short, you can define limitations as predicates and turn them into types. And convenient tooling help to perform validation against those types.

Updates

RefinedTypesOps

Let us have the definition:

opaque type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Temperature]

First of all, now we have apply for cases when validation could be performed at compile time.

val temperature = Temperature(100)

Most likely, real usage will be the following:

val temperature = Temperature.either(runtimeVariable)

The point here is that a convenient companion object will pack the value into opaque type.

The neat detail I want to mention is the implication of constraints.

val x: Double :| Positive = 5.0
val y: Double :| Greater[10] = 15.0
val t1 = Temperature.fromIronType(x)
val t2 = Temperature.fromIronType(y)

Double :| Greater[10] is not of the same type but it is positive and could be used to create a Temperature without explicit validation.

implementation insights

I undertook the feature and Raphaël Fromentin nudged me to use Match Types.

RefinedTypeOps[T] has a single parameter and looks for existing IronType without explicitly mentioning parameters for IronType itself! Very neat, and no macros are required.

type RefinedTypeOps[T] = T match
  case IronType[a, c] => RefinedTypeOpsImpl[a, c, T]

And RefinedTypeOpsImpl handles some functions:

class RefinedTypeOpsImpl[A, C, T]:
  inline def apply(value: A)(using Constraint[A, C]): T =
    autoRefine[A, C](value).asInstanceOf[T]
...

value!

Reminder, Temperature is opaque type. By default, you can’t access the underlying value. So, you know that there is a Double inside, but you have no access. value function is here to help!

val t1 = Temperature(100)
val t2 = Temperature(100)

val result = t1.value + t2.value

value is enabled by extension for iron types with companion objects. The cool thing here is that value returns the underlying IronType, and that type is the subtype of the underlying primitive.

  • t1.value + t2.value is a legitimate operation considered the sum of two doubles. No .value.value calls to reach primitives!
  • t1.value itself is Double :| Positive and can be used appropriately. E.x. use fromIronType with inherent implication to re-pack into different iron types.

NewType?

With all those features enabled, we can use iron as convenient tooling to build lightweight value objects on top of the opaque types. The library focuses on precise validation, so better language should be derived. True predicate (which is always valid) is in place already.

opaque type FirstName = String :| True
object FirstName extends RefinedTypeOps[FirstName]

And apply works for any value in runtime!

val firstName = FirstName(anyRuntimeString)

The next potential improvements are:

  • Provide an alias for True to depict that it is not a constraint but a tool to build value objects without validation: opaque type FirstName = String :| Pure
  • Make an alias for A :| Pure. E.x. opaque type FirstName = NewType[String].

The first option is convenient for initial domain modeling with the connotation of a placeholder that will be replaced later. And NewType simply explicitly says “no validation here, just value object”

I suggest jumping into iron/Issue#125: Add alias for True constraint and IronType[A, True] to share thoughts or implement it!

Thank you for reading!