Skip to content

Commit

Permalink
finished post about encoding invariants in scala
Browse files Browse the repository at this point in the history
  • Loading branch information
rmorais committed Jul 10, 2019
1 parent 4e67763 commit 98d1c01
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 60 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ public
.bloop/
.metals/
target/
/hugo/content/
/hugo/content/
.idea/
137 changes: 78 additions & 59 deletions posts/encoding-invariants-scala.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: "Encoding Invariants Scala"
date: 2019-04-05T11:00:16+01:00
lastmod: 2019-04-05T11:00:16+01:00
draft: true
title: "Encoding Invariants in Scala"
date: 2019-06-30T11:00:16+01:00
lastmod: 2019-06-30T11:00:16+01:00
draft: false
keywords: []
description: ""
tags: [Scala, Invariants, Type Safety]
Expand All @@ -23,47 +23,45 @@ mathjax: false
<!--more-->

## Motivation
Scala is a statically type language with a very powerful type systems. We can take advantage of this and let the compiler make our programs more robust.

I want to take advantage of this and explore how we can use the compiler to help us create more robust types which can't have invalid values.
Scala is a statically type language with a very powerful type system. We can take advantage of this and let the compiler make our programs more robust.

Let's say we want to encode a `TimeFrame` which contains a `StartDate` and an `EndDate`.
I want to show how we can take advantage of this and explore how we can use the compiler to help us create more robust types which can't have invalid values.

We can use a case class to encode it.
Let's say we want to encode a `Counter`. We can use a case class to encode it.

```scala mdoc
import java.time.LocalDate

case class TimeFrame(startDate: LocalDate, endDate: LocalDate)
case class Counter(value: Int)
```

Now imagine that we want to impose a restriction on the allowed values. Let's say the `EndDate` should always be greater than `StartDate`.
Now imagine that we want our counter to have only positive values. There is another requirement that non positive values should default to the initial value of `1`.
There are a couple of ways we can achieve this which I will explore below.

This example is a bit contrived but will suffice for the purpose of our demonstration.

## Using preconditions

Scala provides a set of preconditions that we use to validate our data, being one of them `require`.
Scala provides a set of preconditions that we can use to validate our data, being one of them `require`.
We can use it in the following way.

```scala mdoc:reset
import java.time.LocalDate

case class TimeFrame(startDate: LocalDate, endDate: LocalDate) {
require(endDate.isAfter(startDate))
case class Counter(value: Int) {
require(value > 0)
}
```

This will make sure that we can't create an instance of `TimeFrame` that doesn't respect the restriction, as we can see bellow.
This will make sure that we can't create an instance of `Counter` that doesn't respect the restriction, as we can see bellow.

```scala mdoc:crash

TimeFrame(LocalDate.now, LocalDate.now)
Counter(-1)

```
As the values provided doesn't respect the pre conditiion, `require` will throw an exception.
While this solves our problem, it's not a good solution.

The main issue is that it will fail at runtime. Also the user of this class, doesn't know that creating an instance can fail. This can lead to unexpected errors while running the application.
As the value provided doesn't respect the pre condition `require` will throw an exception.
While this partially solves our problem, it isn't a good solution.

One issue is that it will fail at runtime whenever we create an instance with a negative value. The other problem is that users of this class don't know that it can fail. This can lead to unexpected errors while running the application.

In this case we are not really using the compiler in our favour.
Let's see if we can leverage the scala type system and lift this restriction into the type level.
Expand All @@ -77,74 +75,95 @@ Let's see how it looks like.

```scala mdoc:reset

import java.time.LocalDate
final case class Counter private (value: Int)

val today = LocalDate.now
val yesterday = today.minusDays(1)

case class TimeFrame private (startDate: LocalDate, endDate: LocalDate)

object TimeFrame {
def smartConstructor(startDate: LocalDate, endDate: LocalDate): Either[String, TimeFrame] =
if (endDate.isAfter(startDate))
Right(TimeFrame(startDate, endDate))
else
Left(s"Error: endDate [$endDate] must be after startDate [$startDate]")
object Counter {
def fromInt(value: Int): Counter = if (value > 0) Counter(value) else Counter(1)
}
```

```scala mdoc:fail
new TimeFrame(today, yesterday) {}
new Counter(20)
```

As you can see, we can no longer use the default constructor.
Now that we have define our own constructor, we can create only valid instances.
Now that we have define our own constructor, we can create valid instances.

```scala mdoc
val fail = TimeFrame.smartConstructor(startDate = today, endDate = yesterday)
Counter.fromInt(-3)
```
The custom constructor now returns a `Either[String, TimeFrame]`. This informs our user that they must deal with a possible failure.
We are no longer rely on runtime validation but are instead using the compiler to inforce this using the type system.

From the example above, when we try to construct an invalid instance we will get a `Left` indicating that something has failed. The user has to explicitly handle this.
The custom constructor will always return a valid instance for our counter.
We are no longer relying on runtime validation but are instead relying on compile time validation.

Given that we are using a case class, the compiler will generate some synthetic methods for us. Generally, these are quite handy but for our case they are causing some harm.

`apply` or `copy` are two of the synthetic methods generated. And they can be used to bypass our smart constructor as we can see below.
`apply` and `copy` are two of the synthetic methods generated. And they can be used to bypass our smart constructor as we can see below.

```scala mdoc
val t = TimeFrame(startDate = today, endDate = yesterday)
val c = Counter.apply(-5)

val t1 = TimeFrame(startDate = today, endDate = today.plusDays(1))
val c1 = Counter(10).copy(-5)

val t2 = t1.copy(endDate = yesterday)
```

To fix this, we need to tweaks our smart constructor. We will need to define our own `apply` and `copy` to supress the synthetic ones.
To fix this, we need to tweak our smart constructor. We will need to define our own `apply` and `copy` to supress the synthetic ones.

```scala mdoc:reset
final case class Counter private (value: Int) {
def copy(number: Int = value): Counter = Counter.fromInt(number)
}

import java.time.LocalDate

val today = LocalDate.now
val yesterday = today.minusDays(1)
object Counter {
def apply(value: Int): Counter = fromInt(value)

case class TimeFrame private (startDate: LocalDate, endDate: LocalDate) {
def copy(startDate: LocalDate = startDate, endDate: LocalDate = endDate): TimeFrame = TimeFrame(starDate, endDate)
def fromInt(value: Int): Counter =
if (value > 0) new Counter(value) else new Counter(1)
}
```

Now if we try to use again the `apply` or the `copy` methods, they will just delegate to the smart constructor.

```scala mdoc
val c = Counter.apply(-5)

object TimeFrame {
def apply(startDate: LocalDate, endDate: LocalDate): TimeFrame = new TimeFrame(startDate, endDate)
val c1 = Counter(10).copy(-5)
```

This solution thicks all the boxes for our problem. The downside is that we have to remember to suppress the synthetic methods and this only works from scala `2.12.2+`.

## Using sealed abstract case classes

The last alternative I want to explore is to use an abstract class together with anonynous subclassing. It looks like this:

```scala mdoc:reset

sealed abstract case class Counter private (value: Int)

object Counter {
def fromInt(value: Int): Counter =
if (value > 0) new Counter(value) {} else new Counter(1) {}
}
```

We define our counter as a `sealed` class, to limit the scope in which subclasses can be defined. In this case the scope is limited to the source file where our counter is defined.
And by defining our case class `abstract`, we disable the synthetic methods `apply` and `copy` provided by the compiler. It cannot generate this methods because there is no default constructor.

The only possible way to create an instance of our counter is through the use of our smart constructor.

As we can see below, the synthetic methods are not available, reducing the ways of bypassing our smart constructor.

```scala mdoc:fail
TimeFrame(today, today.plusDays(1)).copy(endDate = yesterday)
val c1 = Counter.apply(-5)

val c2 = Counter.fromInt(3).copy(-5)

```

This last solution is more safe because the compiler can't generate `apply` and `copy` methods. The downside is that we have to define a `sealed abstract` class plus anonymous subtyping instead of just a `final` class.

## Using Sealed abstract case classes
```scala mdoc:reset
I prefer this last option mainly because I don't have to remember to supress the shyntetic methods generated by the compiler.

import java.time.LocalDate
I think both alternatives works well and there are no clear advantages or disadvantages of using one over the other. It's more of a style preference and I find myself enforcing invariants by using primarly `sealed abstract` classes.

sealed abstract case class TimeFrame(startDate: LocalDate, endDate: LocalDate)
```
[refined]: https://github.com/fthomas/refined

0 comments on commit 98d1c01

Please sign in to comment.