Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Update RefinedTypeOps to support future Scala versions #177

Merged
merged 6 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading