Iron updates: turning opaque types into value objects
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 standardapply
andvalue
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 isDouble :| Positive
and can be used appropriately. E.x. usefromIronType
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!