Skip to content

Commit

Permalink
feat: Update RefinedTypeOps to support future Scala versions (#177)
Browse files Browse the repository at this point in the history
  • Loading branch information
Iltotore authored Oct 2, 2023
1 parent 07460a7 commit f67037d
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 71 deletions.
2 changes: 1 addition & 1 deletion cats/src/io/github/iltotore/iron/cats.scala
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ object cats extends IronCatsInstances:
inline def refineFurtherValidatedNel[C2](using inline constraint: Constraint[A, C2]): ValidatedNel[String, A :| (C1 & C2)] =
(value: A).refineValidatedNel[C2].map(_.assumeFurther[C1])

extension [A, C, T](ops: RefinedTypeOpsImpl[A, C, T])
extension [A, C, T](ops: RefinedTypeOps[A, C, T])

/**
* Refine the given value at runtime, resulting in an [[EitherNec]].
Expand Down
4 changes: 2 additions & 2 deletions cats/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import io.github.iltotore.iron.constraint.numeric.Positive

//Opaque types are truly opaque when used in another file than the one where they're defined. See Scala documentation.
opaque type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Temperature]
object Temperature extends RefinedTypeOps[Double, Positive, Temperature]

type Moisture = Double :| Positive
object Moisture extends RefinedTypeOps[Moisture]
object Moisture extends RefinedTypeOps.Transparent[Moisture]
46 changes: 33 additions & 13 deletions docs/_docs/reference/newtypes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import io.github.iltotore.iron.constraint.numeric.Positive

//}
type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Temperature]
object Temperature extends RefinedTypeOps[Double, Positive, Temperature]
```

```scala
Expand All @@ -26,7 +26,7 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

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

//}
val temperature = Temperature(15) //Compiles
Expand All @@ -36,6 +36,18 @@ val positive: Double :| Positive = 15
val tempFromIron = Temperature(positive) //Compiles too
```

For transparent type aliases, it is possible to use the `RefinedTypeOps.Transparent` alias to avoid boilerplate.

```scala
//{
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

//}
type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps.Transparent[Temperature]
```

### Runtime refinement

`RefinedTypeOps` supports [all refinement methods](refinement.md) provided by Iron:
Expand All @@ -46,7 +58,7 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

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

//}
val unsafeRuntime: Temperature = Temperature.applyUnsafe(15)
Expand All @@ -65,7 +77,7 @@ import io.github.iltotore.iron.constraint.numeric.Positive
import io.github.iltotore.iron.zio.*

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

//}
val zioValidation: Validation[String, Temperature] = Temperature.validation(15)
Expand All @@ -79,7 +91,7 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

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

//}
val temperature: Temperature = Temperature(15)
Expand Down Expand Up @@ -112,15 +124,15 @@ import io.github.iltotore.iron.constraint.any.Pure

//}
type FirstName = String :| Pure
object FirstName extends RefinedTypeOps[FirstName]
object FirstName extends RefinedTypeOps.Transparent[FirstName]
```
```scala
//{
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.any.Pure

type FirstName = String :| Pure
object FirstName extends RefinedTypeOps[FirstName]
object FirstName extends RefinedTypeOps.Transparent[FirstName]

//}
val firstName = FirstName("whatever")
Expand All @@ -137,6 +149,7 @@ import io.github.iltotore.iron.constraint.numeric.Positive

//}
opaque type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Double, Positive, Temperature]
```

```scala
Expand All @@ -145,6 +158,7 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

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

//}
val x: Double :| Positive = 5
Expand All @@ -160,7 +174,10 @@ import io.github.iltotore.iron.constraint.numeric.Positive

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

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

```scala
Expand All @@ -169,7 +186,10 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

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

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

//}
case class Info(temperature: Temperature, moisture: Moisture)
Expand All @@ -190,7 +210,7 @@ import io.github.iltotore.iron.constraint.numeric.Positive

//}
opaque type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Temperature]
object Temperature extends RefinedTypeOps[Double, Positive, Temperature]
```

```scala
Expand All @@ -199,7 +219,7 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

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

//}
val value: Double :| Positive = ???
Expand All @@ -224,7 +244,7 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

opaque type FirstName = String :| ForAll[Letter]
object FirstName extends RefinedTypeOps[FirstName]
object FirstName extends RefinedTypeOps[String, ForAll[Letter], FirstName]
```

We cannot use `java.lang.String`'s methods neither pass `FirstName` as a String without using the `value`
Expand Down Expand Up @@ -255,7 +275,7 @@ import io.github.iltotore.iron.constraint.all.*

//}
opaque type FirstName <: String :| ForAll[Letter] = String :| ForAll[Letter]
object FirstName extends RefinedTypeOps[FirstName]
object FirstName extends RefinedTypeOps[String, ForAll[Letter], FirstName]
```

```scala
Expand All @@ -264,7 +284,7 @@ import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

opaque type FirstName <: String :| ForAll[Letter] = String :| ForAll[Letter]
object FirstName extends RefinedTypeOps[FirstName]
object FirstName extends RefinedTypeOps[String, ForAll[Letter], FirstName]

//}
val x = FirstName("Raphael")
Expand All @@ -283,7 +303,7 @@ import io.github.iltotore.iron.constraint.numeric.Positive

//}
opaque type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Temperature]
object Temperature extends RefinedTypeOps[Double, Positive, Temperature]
```

To support such type, you can use the [[RefinedTypeOps.Mirror|io.github.iltotore.iron.RefinedTypeOps.Mirror]] provided by
Expand Down
195 changes: 195 additions & 0 deletions docs/_docs/reference/refinement.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,198 @@ val x: Int :| Greater[0] = value //OK
```

## Runtime refinement

Sometimes, you want to refine a value that is not available at compile time. For example in the case of form validation.

```scala
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.string.*

val runtimeString: String = ???
val username: String :| Alphanumeric = runtimeString
```

This snippet would not compile because `runtimeString` is not evaluable at compile time.
Fortunately, Iron supports explicit runtime checking using extension methods

### Imperative

You can imperatively refine a value at runtime (much like an assertion) using the `refine[C]` method:

```scala
val runtimeString: String = ???
val username: String :| Alphanumeric = runtimeString.refine //or more explicitly, refine[LowerCase].
```

The `refine` extension method tests the constraint at runtime, throwing an `IllegalArgumentException` if the value
does not pass the assertion.

### Functional

Iron also provides methods similar to `refine` but returning an `Option` (`refineOption`) or
an `Either` (`refineEither`), useful for data validation:

```scala
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

case class User(name: String :| Alphanumeric, age: Int :| Greater[0])

def createUser(name: String, age: Int): Either[String, User] =
for
n <- name.refineEither[Alphanumeric]
a <- age.refineEither[Greater[0]]
yield User(n, a)

createUser("Il_totore", 18) //Left("Should be alphanumeric")
createUser("Iltotore", 0) //Left("Should be greater than 0")
createUser("Iltotore", 18) //Right(User("Iltotore", 18))
```

### Accumulative error

You can accumulate refinement errors using the [Cats](../modules/cats.md) or [ZIO](../modules/zio.md) module.
Here is an example with the latter:

```scala
import zio.prelude.Validation

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*
import io.github.iltotore.iron.zio.*


type Username = Alphanumeric DescribedAs "Username should be alphanumeric"

type Age = Positive DescribedAs "Age should be positive"

case class User(name: String :| Username, age: Int :| Age)

def createUser(name: String, age: Int): Validation[String, User] =
Validation.validateWith(
name.refineValidation[Username],
age.refineValidation[Age]
)(User.apply)

createUser("Iltotore", 18) //Success(Chunk(),User(Iltotore,18))
createUser("Il_totore", 18) //Failure(Chunk(),NonEmptyChunk(Username should be alphanumeric))
createUser("Il_totore", -18) //Failure(Chunk(),NonEmptyChunk(Username should be alphanumeric, Age should be positive))
```

This is useful for forms where you want to report all input errors to the user and not short-circuit like an `Either`.

Check the [Cats module](../modules/cats.md) or [ZIO module](../modules/zio.md) page for further information.

### Refining further

Sometimes you want to refine the same value multiple times with different constraints.
This is especially useful when you want fine-grained refinement errors. Let's take the last example but with passwords:

```scala
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

type Username = DescribedAs[Alphanumeric, "Username should be alphanumeric"]
type Password = DescribedAs[
Alphanumeric & MinLength[5] & Exists[Letter] & Exists[Digit],
"Password should have at least 5 characters, be alphanumeric and contain at least one letter and one digit"
]

case class User(name: String :| Username, password: String :| Password)

def createUser(name: String, password: String): Either[String, User] =
for
validName <- name.refineEither[Username]
validPassword <- password.refineEither[Password]
yield
User(validName, validPassword)

createUser("Iltotore", "abc123") //Right(User("Iltotore", "abc123"))
createUser("Iltotore", "abc") //Left("Password should have at least 5 characters, be alphanumeric and contain at least one letter and one digit")
```

At the last line, we get a `Left` saying that our password is invalid.
However, it's not clear which constraint is not satisfied: is my password to short? Should I add a digit? etc...

Using `refineFurther`/`refineFurtherEither`/... enables more detailed messages:

```scala
type Username = DescribedAs[Alphanumeric, "Username should be alphanumeric"]
type Password = DescribedAs[
Alphanumeric & MinLength[5] & Exists[Letter] & Exists[Digit],
"Password should have at least 5 characters, be alphanumeric and contain at least one letter and one digit"
]

case class User(name: String :| Username, password: String :| Password)

def createUser(name: String, password: String): Either[String, User] =
for
validName <- name.refineEither[Username]
alphanumeric <- password.refineEither[Alphanumeric]
minLength <- alphanumeric.refineFurtherEither[MinLength[5]]
hasLetter <- minLength.refineFurtherEither[Exists[Letter]]
validPassword <- hasLetter.refineFurtherEither[Exists[Digit]]
yield
User(validName, validPassword)

createUser("Iltotore", "abc123") //Right(User("Iltotore", "abc123"))
createUser("Iltotore", "abc1") //Left("Should have a minimum length of 5")
createUser("Iltotore", "abcde") //Left("At least one element: (Should be a digit)")
createUser("Iltotore", "abc123 ") //Left("Should be alphanumeric")
```

Or with custom error messages:

```scala
type Username = DescribedAs[Alphanumeric, "Username should be alphanumeric"]
type Password = DescribedAs[
Alphanumeric & MinLength[5] & Exists[Letter] & Exists[Digit],
"Password should have at least 5 characters, be alphanumeric and contain at least one letter and one digit"
]

case class User(name: String :| Username, password: String :| Password)

def createUser(name: String, password: String): Either[String, User] =
for
validName <- name.refineEither[Username]
alphanumeric <- password.refineEither[Alphanumeric].left.map(_ => "Your password should be alphanumeric")
minLength <- alphanumeric.refineFurtherEither[MinLength[5]].left.map(_ => "Your password should have a minimum length of 5")
hasLetter <- minLength.refineFurtherEither[Exists[Letter]].left.map(_ => "Your password should contain at least a letter")
validPassword <- hasLetter.refineFurtherEither[Exists[Digit]].left.map(_ => "Your password should contain at least a digit")
yield
User(validName, validPassword)

createUser("Iltotore", "abc123") //Right(User("Iltotore", "abc123"))
createUser("Iltotore", "abc1") //Left("Your password should have a minimum length of 5")
createUser("Iltotore", "abcde") //Left("Your password should contain at least a digit")
createUser("Iltotore", "abc123 ") //Left("Your password should be alphanumeric")
```

Note: Accumulative versions exist for [Cats](../modules/cats.md) and [ZIO](../modules/zio.md).

## Assuming constraints

Sometimes, you know that your value always passes (possibly at runtime) a constraint. For example:

```scala
val random = scala.util.Random.nextInt(9)+1
val x: Int :| Positive = random
```

This code will not compile (see [Runtime refinement](#runtime-refinement)).
We could use `refine` but we don't actually need to apply the constraint to `random`.
Instead, we can can use `assume[C]`. It simply acts like a safer cast.

```scala
val random = scala.util.Random.nextInt(9)+1
val x: Int :| Positive = random.assume
```

This code will compile to:

```scala
val random: Int = scala.util.Random.nextInt(9)+1
val x: Int = random
```

leaving no overhead.
Loading

0 comments on commit f67037d

Please sign in to comment.