Since I published my article Reduce the domain of your types with Refined I received some thank’s and a lot of questions of the “wtf is this library” kind. In this article I aim to answer these questions and to help you understand the benefits and drawbacks of Refined
.
So, what is Refined ?
Refined
is a Scala library, a port of an Haskell
library. It aims at helping developers constrain types.
In the above example I defined a PositiveInt
type. Its possible values range from 0 to Int.MaxValue
. Now, if I want a function to take a positive integer as its input, I can use the newly created PositiveInt
type. Therefore, I ensure that the value it receives has already been validated at some point. So, I don’t need to verify the correctness of the parameters anymore.
How to define a great refined type
Refining types is not enough. You also need to define the companion object of your type.
By inheriting from RefinedTypeOps
all the usual functions are defined for our type. We get apply
and unapply
definitions along with from
and unsafeFrom
. The unapply
function behave simply and let us access the inner value of the refined type, but the apply
function is a bit more complex. It will only work with literal values which are values known at compile time.
Because positiveInt
value (first line) is known at compile time, the apply
function hold. On the other hand, the int
value is a reference, meaning it’s not possible to check it’s value at compile time.
Under the hood (you need to know how macros work)
The apply function call a macro, transforming the Scala code at the call site. The Expr
instance taken by the macro contains only the parameter of the apply
call, no more information. If this Expr
does not contain an instance of Literal
its impossible (with the current Scala
macro implementation) to check what the parameter hold, even if its value have been hard-coded somewhere else.
As for the from
and unsafeFrom
functions, they are meant to wrap a value into your refined type.
The from
function validate the input data and then return an Either
for you to compose with. The unsafeFrom
also validate the input data but will throw an IllegalArgumentException
if the validation failed.
Simpler validations
Refined
can simplify your validations while also making them safer. Instead of writing custom ones, I recommend you refine your types and simply use the build-in validator.
id
and code
are used to build instances of Id
and CodeArticle
using the from
function. Because this function returns an Either[String, RefineType]
we can chain this call in a for-comprehension. Mapping the error messages to your application error system is quite trivial.
But maybe you dont want your validation to stop at the first one ? For this you can use a Validated
from the cats library.
Business Rules in the Type Systems
As seen with Id
and CodeArticle
the refined types can have business value. In other words, Refined
let you engrave business into the Type System.
These examples are from a real production project
The advantage of having your business model in the Type System is, before all, clarity.
To me the first definition seems clearer, as I can very simply find what a PhoneNumber
is. Using the same words inside the code as in project talks greatly decreases the complexity. As a side effect, it’s much more simpler for newcomers to learn about the business just by reading code.
Advanced Usages (you can skip this):
By having the validation inside the type system one can derive a lot of boilerplate from it. For example, we used it to derive the Json-Schema of our types, which gave birth to the Restruct library.
Easier testing
Testing is writing in-code specs. ScalaTest promote the use of Specs at the end of the tests classes names for this very same reason. Refined
enforce business rules in the Type System, therefore reducing the range of values a type can take. The more precise a function definition is, the less test it needs.
As you can see, we firstly capture the good execution of the function and lastly describe a precise behavior. The second test wont even compile as the siret value has the wrong format.
Putting it simply, a function can be used in two contexts : before input data is validated or after. As validation is a side-effect, functions working with non-valid data are rare, and the other group is the majority. For this majority, inputs should be strictly typed. It avoid protective programming (no IllegalArgumentException
) and transform the tests of this function into specs, as we can describe the nominal behavior of the function alone. Tests then become references we can trust.
Drawbacks — Base Type
For now I have talked a lot about the advantages of Refined
but nothing is perfect.
Refined value will not behave like the base type they were made from. This is an in-progress work and the next release should contain a fix for this. Still, for now it’s possible to use refined values as base types in function calls only.
While this work fine, it is cumbersome and is error prone for developers new to Refined
. To avoid this behavior here is my personal solution :
You will need to import RefinedOps._
every time a refined value should be converted to the base type.
Note : This part will be updated when a new version is available.
DrawBacks — Compilation Time
Refined
use Shapeless
inside and a lot of it’s own macros, which directly impact the compilation time. This can become a problem for large projects, but solutions exist. The simpler one is to split a project into smaller, multiple ones. The compilation will only happen on the project you modified.
It may sound like a huge change to support a simple library, but this is a problem only for really large projects. For other reasons, which are not in the scope of this article, I would argue that this projects should already have been split into smaller ones.
Still, tests are a place where we will use a lot of values.
The three values here are wrap at compile time thanks to the auto._
import. If you end up creating hundreds, the compilation cost will be high, in the order of twenty seconds for every hundred.
This can be overcome by calling unsafeFrom
by hand.
This way no compilation time overhead. Nevertheless, it comes at a runtime cost, which is way lower.
Finaly, if the runtime overhead is still too much, another function can be called.
Beware, this is basically an asInstanceOf
. The unsafeApply
function does not check the value you pass it, possibly creating an error in your input.
DrawBacks — Singleton Types
As you have seen in the examples of this whole article, Refined
is a good match with Singleton Types. A type is called singleton if reduced to a value.
The Id
type is built using two singletons : 100 and 500. They are types, you can see how I use them.
If this does not seems like simple Scala to you, that’s perfectly normal. Singleton types were compiler internals until Scala 2.13. If you need to support a lower language version, you can depends on Typelevel Scala. This fork of the Scala compiler support singleton types since Scala 2.11
The other possibility, without switching compiler, is to use the Shapeless notation, which support Scala 2.10.
As you can see, Shapeless notation is harder to read. Still, it’s the recommended way in Refined
examples for now as in Scala 2.12 Singleton Types were not usable.
Conclusion
Refined
is a library to reduce the values a type can take (type refinement). It can be of help to enforce business rules in the TypeLevel, giving you power over functions by declaring clearer parameters. This way, you make sure values are valid in every functions and nothing can have break, simplifying both, validation and testing. Validation can be made in one unique place, and Refined
offer built-in functions. Testing only need to care about business cases, not technical ones, as your functions inputs will necessary be valid.
This come at some cost, which can be mostly avoided. It may add boilerplate for now, but a workaround exist and the library will soon be patched. Compilation time can become a burden, especially tests compilation. This can be avoided by calling the right function and avoid auto-wrapping at compile time. Lastly, it’s much clearer with Singleton Types but they were not part of Scala before 2.13. The alternatives are the TypeLevel compiler or the Shapeless Notation.