diff --git a/cats/src/io/github/iltotore/iron/cats.scala b/cats/src/io/github/iltotore/iron/cats.scala index 6816f638..9541243f 100644 --- a/cats/src/io/github/iltotore/iron/cats.scala +++ b/cats/src/io/github/iltotore/iron/cats.scala @@ -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]]. diff --git a/cats/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala b/cats/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala index 744d8bf3..1a6bacb8 100644 --- a/cats/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala +++ b/cats/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala @@ -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] diff --git a/docs/_docs/reference/newtypes.md b/docs/_docs/reference/newtypes.md index 6bc4009f..bb6606e0 100644 --- a/docs/_docs/reference/newtypes.md +++ b/docs/_docs/reference/newtypes.md @@ -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 @@ -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 @@ -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: @@ -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) @@ -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) @@ -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) @@ -112,7 +124,7 @@ import io.github.iltotore.iron.constraint.any.Pure //} type FirstName = String :| Pure -object FirstName extends RefinedTypeOps[FirstName] +object FirstName extends RefinedTypeOps.Transparent[FirstName] ``` ```scala //{ @@ -120,7 +132,7 @@ 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") @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 = ??? @@ -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` @@ -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 @@ -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") @@ -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 diff --git a/docs/_docs/reference/refinement.md b/docs/_docs/reference/refinement.md index 9ea450d3..9d2e1802 100644 --- a/docs/_docs/reference/refinement.md +++ b/docs/_docs/reference/refinement.md @@ -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. \ No newline at end of file diff --git a/main/src/io/github/iltotore/iron/RefinedTypeOps.scala b/main/src/io/github/iltotore/iron/RefinedTypeOps.scala index d5d401bf..8d945507 100644 --- a/main/src/io/github/iltotore/iron/RefinedTypeOps.scala +++ b/main/src/io/github/iltotore/iron/RefinedTypeOps.scala @@ -3,58 +3,25 @@ package io.github.iltotore.iron import scala.compiletime.summonInline import scala.reflect.TypeTest -type RefinedTypeOps[T] = T match - case IronType[a, c] => RefinedTypeOpsImpl[a, c, T] - -object RefinedTypeOps: +/** + * Utility trait for new types' companion object. + * + * @tparam A the base type of the new type + * @tparam C the constraint type of the new type + * @tparam T the new type (equivalent to `A :| C` if `T` is a transparent alias) + */ +trait RefinedTypeOps[A, C, T](using val rtc: RuntimeConstraint[A, C]): /** - * Typelevel access to a "new type"'s informations. It is similar to [[scala.deriving.Mirror]]. - * @tparam T the new type (usually a type alias). + * R + * @return */ - trait Mirror[T]: - - /** - * The base type of the mirrored type without any constraint. - */ - type BaseType - - /** - * The constraint of the mirrored type. - */ - type ConstraintType - - /** - * Alias for `BaseType :| ConstraintType` - */ - type IronType = BaseType :| ConstraintType - - /** - * Alias for [[T]]. Also equivalent to [[IronType]] if the type alias of the mirrored new type is transparent. - * - * {{{ - * type Temperature = Double :| Positive - * object Temperature extends RefinedTypeOps[Temperature] - * - * //FinalType =:= IronType - * }}} - * - * {{{ - * opaque type Temperature = Double :| Positive - * object Temperature extends RefinedTypeOps[Temperature] - * - * //FinalType =/= IronType - * }}} - */ - type FinalType = T - -trait RefinedTypeOpsImpl[A, C, T](using val rtc: RuntimeConstraint[A, C]): inline protected given RuntimeConstraint[A, C] = rtc /** * Implicitly refine at compile-time the given value. * - * @param value the value to refine. + * @param value the value to refine. * @tparam A the refined type. * @tparam C the constraint applied to the type. * @return the given value typed as [[IronType]] @@ -105,10 +72,61 @@ trait RefinedTypeOpsImpl[A, C, T](using val rtc: RuntimeConstraint[A, C]): override type BaseType = A override type ConstraintType = C - inline given [R]: TypeTest[T, R] = summonInline[TypeTest[A :| C, R]].asInstanceOf[TypeTest[T, R]] + inline given[R]: TypeTest[T, R] = summonInline[TypeTest[A :| C, R]].asInstanceOf[TypeTest[T, R]] - given [L](using test: TypeTest[L, A]): TypeTest[L, T] with + given[L] (using test: TypeTest[L, A]): TypeTest[L, T] with override def unapply(value: L): Option[value.type & T] = test.unapply(value).filter(rtc.test(_)).asInstanceOf extension (wrapper: T) inline def value: IronType[A, C] = wrapper.asInstanceOf[IronType[A, C]] + + +object RefinedTypeOps: + + /** + * Alias to reduce boilerplate for transparent type aliases. + * + * @tparam T the new type which should be a transparent alias for an [[IronType]] + */ + type Transparent[T] = T match + case a :| c => RefinedTypeOps[a, c, T] + + /** + * Typelevel access to a "new type"'s informations. It is similar to [[scala.deriving.Mirror]]. + * @tparam T the new type (usually a type alias). + */ + trait Mirror[T]: + + /** + * The base type of the mirrored type without any constraint. + */ + type BaseType + + /** + * The constraint of the mirrored type. + */ + type ConstraintType + + /** + * Alias for `BaseType :| ConstraintType` + */ + type IronType = BaseType :| ConstraintType + + /** + * Alias for [[T]]. Also equivalent to [[IronType]] if the type alias of the mirrored new type is transparent. + * + * {{{ + * type Temperature = Double :| Positive + * object Temperature extends RefinedTypeOps[Temperature] + * + * //FinalType =:= IronType + * }}} + * + * {{{ + * opaque type Temperature = Double :| Positive + * object Temperature extends RefinedTypeOps[Temperature] + * + * //FinalType =/= IronType + * }}} + */ + type FinalType = T \ No newline at end of file diff --git a/main/test/src/io/github/iltotore/iron/testing/RefinedOpsTypes.scala b/main/test/src/io/github/iltotore/iron/testing/RefinedOpsTypes.scala index a55eae3c..877cab75 100644 --- a/main/test/src/io/github/iltotore/iron/testing/RefinedOpsTypes.scala +++ b/main/test/src/io/github/iltotore/iron/testing/RefinedOpsTypes.scala @@ -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] diff --git a/main/test/src/io/github/iltotore/iron/testing/RefinedOpsSuite.scala b/main/test/src/io/github/iltotore/iron/testing/RefinedTypeOpsSuite.scala similarity index 87% rename from main/test/src/io/github/iltotore/iron/testing/RefinedOpsSuite.scala rename to main/test/src/io/github/iltotore/iron/testing/RefinedTypeOpsSuite.scala index 6c9e0ee3..748ea2f6 100644 --- a/main/test/src/io/github/iltotore/iron/testing/RefinedOpsSuite.scala +++ b/main/test/src/io/github/iltotore/iron/testing/RefinedTypeOpsSuite.scala @@ -41,9 +41,9 @@ object RefinedTypeOpsSuite extends TestSuite: test("option") { val fromWithFailingPredicate = Temperature.option(-5.0) - assert(fromWithFailingPredicate == None) + assert(fromWithFailingPredicate.isEmpty) val fromWithSucceedingPredicate = Temperature.option(100) - assert(fromWithSucceedingPredicate == Some(Temperature(100))) + assert(fromWithSucceedingPredicate.contains(Temperature(100))) } test("applyUnsafe") { @@ -63,8 +63,8 @@ object RefinedTypeOpsSuite extends TestSuite: test - assert(Moisture(positive) == moisture) test - assert(Moisture.either(-5.0) == Left("Should be strictly positive")) test - assert(Moisture.either(100) == Right(Moisture(100))) - test - assert(Moisture.option(-5.0) == None) - test - assert(Moisture.option(100) == Some(Moisture(100))) + test - assert(Moisture.option(-5.0).isEmpty) + test - assert(Moisture.option(100).contains(Moisture(100))) } test("mirror") { @@ -74,4 +74,9 @@ object RefinedTypeOpsSuite extends TestSuite: assertGiven[mirror.ConstraintType =:= Positive] assertGiven[mirror.FinalType =:= Temperature] } + + test("value") { + val temperature = Temperature(10) + assert(temperature.value == 10) + } } diff --git a/zio/src/io/github/iltotore/iron/zio.scala b/zio/src/io/github/iltotore/iron/zio.scala index 4e0e1d1a..14b686c7 100644 --- a/zio/src/io/github/iltotore/iron/zio.scala +++ b/zio/src/io/github/iltotore/iron/zio.scala @@ -25,7 +25,7 @@ object zio extends RefinedTypeOpsZio: inline def refineFurtherValidation[C2](using inline constraint: Constraint[A, C2]): Validation[String, A :| (C1 & C2)] = (value: A).refineValidation[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 applicatively at runtime, resulting in a [[Validation]]. * diff --git a/zio/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala b/zio/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala index 744d8bf3..1a6bacb8 100644 --- a/zio/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala +++ b/zio/test/src/io/github/iltotore/iron/RefinedOpsTypes.scala @@ -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]