Skip to content

Commit

Permalink
feat: Support first order types (#222)
Browse files Browse the repository at this point in the history
  • Loading branch information
Iltotore committed Feb 15, 2024
1 parent 6bb6bfa commit 50b3fb3
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 26 deletions.
7 changes: 6 additions & 1 deletion cats/src/io/github/iltotore/iron/cats.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import _root_.cats.kernel.{CommutativeMonoid, Hash, LowerBounded, PartialOrder,
import _root_.cats.syntax.either.*
import _root_.cats.{Eq, Monoid, Order, Show}
import _root_.cats.data.Validated.{Invalid, Valid}
import io.github.iltotore.iron.constraint.numeric.{Greater, Less, Positive, Negative}
import _root_.cats.Functor
import io.github.iltotore.iron.constraint.numeric.{Greater, Less, Negative, Positive}

import scala.util.NotGiven

Expand Down Expand Up @@ -172,6 +173,10 @@ object cats extends IronCatsInstances:
* Represent all Cats' typeclass instances for Iron.
*/
private trait IronCatsInstances extends IronCatsLowPriority, RefinedTypeOpsCats:

given [F[_]](using functor: Functor[F]): MapLogic[F] with

override def map[A, B](wrapper: F[A], f: A => B): F[B] = functor.map(wrapper)(f)

// The `NotGiven` implicit parameter is mandatory to avoid ambiguous implicit error when both Eq[A] and Hash[A]/PartialOrder[A] exist
inline given [A, C](using inline ev: Eq[A], notHashOrOrder: NotGiven[Hash[A] | PartialOrder[A]]): Eq[A :| C] = ev.asInstanceOf[Eq[A :| C]]
Expand Down
5 changes: 5 additions & 0 deletions cats/test/src/io/github/iltotore/iron/CatsSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,9 @@ object CatsSuite extends TestSuite:
val validatedNelWithSucceedingPredicate = Temperature.validatedNel(100)
assert(validatedNelWithSucceedingPredicate == Valid(Temperature(100)), "valid should contain result of 'apply'")
}

test("refineAll") {
test - assert(Temperature.optionAll(NonEmptyList.of(1, 2, -3)).isEmpty)
test - assert(Temperature.optionAll(NonEmptyList.of(1, 2, 3)).contains(NonEmptyList.of(Temperature(1), Temperature(2), Temperature(3))))
}
}
11 changes: 11 additions & 0 deletions docs/_docs/reference/refinement.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,17 @@ createUser("Iltotore", "abc123 ") //Left("Your password should be alphanumeric"

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

### Refining first order types

Iron provides utility methods to easily refine first order types (e.g container types like `List`, `Future`, `IO`...).

```scala
List(1, 2, 3).refineAllUnsafe[Positive] //List(1, 2, 3): List[Int :| Positive]
List(1, 2, -3).refineAllUnsafe[Positive] //IllegalArgumentException
```

Variants exist for `Option/Either`, `assume`, `...Further` as well as `RefinedTypeOps` constructors.

## Assuming constraints

Sometimes, you know that your value always passes (possibly at runtime) a constraint. For example:
Expand Down
27 changes: 27 additions & 0 deletions main/src/io/github/iltotore/iron/MapLogic.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.github.iltotore.iron

import scala.collection.IterableOnceOps
import scala.concurrent.{ExecutionContext, Future}

/**
* A typeclass providing a `map` method. Mainly used to abstract over Cats and ZIO Prelude.
*
* @tparam F the wrapper type
*/
trait MapLogic[F[_]]:

def map[A, B](wrapper: F[A], f: A => B): F[B]

object MapLogic:

given [C, CC[x] <: IterableOnceOps[x, CC, C]]: MapLogic[CC] with

def map[A, B](wrapper: CC[A], f: A => B): CC[B] = wrapper.map(f)

given [L]: MapLogic[[x] =>> Either[L, x]] with

override def map[A, B](wrapper: Either[L, A], f: A => B): Either[L, B] = wrapper.map(f)

given (using ExecutionContext): MapLogic[Future] with

override def map[A, B](wrapper: Future[A], f: A => B): Future[B] = wrapper.map(f)
68 changes: 58 additions & 10 deletions main/src/io/github/iltotore/iron/RefinedTypeOps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package io.github.iltotore.iron

import scala.compiletime.summonInline
import scala.reflect.TypeTest
import scala.util.boundary
import scala.util.boundary.break

/**
* Utility trait for new types' companion object.
Expand Down Expand Up @@ -30,13 +32,23 @@ trait RefinedTypeOps[A, C, T](using private val _rtc: RuntimeConstraint[A, C]):
inline def apply(value: A :| C): T = value.asInstanceOf[T]

/**
* Refine the given value at runtime, assuming the constraint holds.
* Refine the given value, assuming the constraint holds.
*
* @return a constrained value, without performing constraint checks.
* @see [[apply]], [[applyUnsafe]].
* @see [[assumeAll]], [[apply]], [[applyUnsafe]].
*/
inline def assume(value: A): T = value.asInstanceOf[T]

/**
* Refine the given value at runtime.
*
* @return this value as [[T]].
* @throws an [[IllegalArgumentException]] if the constraint is not satisfied.
* @see [[fromIronType]], [[either]], [[option]].
*/
inline def applyUnsafe(value: A): T =
if rtc.test(value) then value.asInstanceOf[T] else throw new IllegalArgumentException(rtc.message)

/**
* Refine the given value at runtime, resulting in an [[Either]].
*
Expand All @@ -48,23 +60,59 @@ trait RefinedTypeOps[A, C, T](using private val _rtc: RuntimeConstraint[A, C]):

/**
* Refine the given value at runtime, resulting in an [[Option]].
*
* @param constraint the constraint to test with the value to refine.
* @return an Option containing this value as [[T]] or [[None]].
* @see [[fromIronType]], [[either]], [[applyUnsafe]].
*/
def option(value: A): Option[T] =
Option.when(rtc.test(value))(value.asInstanceOf[T])

/**
* Refine the given value at runtime.
* Refine the given value(s), assuming the constraint holds.
*
* @return this value as [[T]].
* @throws an [[IllegalArgumentException]] if the constraint is not satisfied.
* @see [[fromIronType]], [[either]], [[option]].
* @return a wrapper of constrained values, without performing constraint checks.
* @see [[assume]].
*/
inline def applyUnsafe(value: A): T =
if rtc.test(value) then value.asInstanceOf[T] else throw new IllegalArgumentException(rtc.message)
inline def assumeAll[F[_]](wrapper: F[A]): F[T] = wrapper.asInstanceOf[F[T]]

/**
* Refine the given value(s) at runtime.
*
* @return the given values as [[T]].
* @throws IllegalArgumentException if the constraint is not satisfied.
* @see [[applyUnsafe]].
*/
inline def applyAllUnsafe[F[_]](wrapper: F[A])(using mapLogic: MapLogic[F]): F[T] =
mapLogic.map(wrapper, applyUnsafe(_))

/**
* Refine the given value(s) at runtime, resulting in an [[Either]].
*
* @return a [[Right]] containing the given values as [[T]] or a [[Left]] containing the constraint message.
* @see [[either]].
*/
inline def eitherAll[F[_]](wrapper: F[A])(using mapLogic: MapLogic[F]): Either[String, F[T]] =
boundary:
Right(mapLogic.map(
wrapper,
either(_) match
case Right(value) => value
case Left(error) => break(Left(error))
))

/**
* Refine the given value at runtime, resulting in an [[Option]].
*
* @return an Option containing the refined values as `F[T]` or [[None]].
* @see [[option]].
*/
inline def optionAll[F[_]](wrapper: F[A])(using mapLogic: MapLogic[F]): Option[F[T]] =
boundary:
Some(mapLogic.map(
wrapper,
option(_) match
case Some(value) => value
case None => break(None)
))

def unapply(value: T): Option[A :| C] = Some(value.asInstanceOf[A :| C])

Expand Down
59 changes: 57 additions & 2 deletions main/src/io/github/iltotore/iron/conversion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package io.github.iltotore.iron
import io.github.iltotore.iron.constraint.collection.ForAll

import scala.language.implicitConversions
import scala.util.boundary
import scala.util.boundary.break

/**
* Implicitly refine at compile-time the given value.
Expand Down Expand Up @@ -62,10 +64,10 @@ implicit inline def autoDistribute[A, I[_] <: Iterable[?], C1, C2](inline iterab
extension [A, C1](value: A :| C1)

/**
* Refine the given value again at runtime, assuming the constraint holds.
* Refine the given value again, assuming the constraint holds.
*
* @return a constrained value, without performing constraint checks.
* @see [[assume]].
* @see [[assume]], [[assumeAllFurther]].
*/
inline def assumeFurther[C2]: A :| (C1 & C2) = (value: A).assume[C1 & C2]

Expand Down Expand Up @@ -112,6 +114,59 @@ extension [A, C1](value: A :| C1)
inline def refineFurtherOption[C2](using inline constraint: Constraint[A, C2]): Option[A :| (C1 & C2)] =
(value: A).refineOption[C2].map(_.assumeFurther[C1])

extension[F[_], A, C1] (wrapper: F[A :| C1])

/**
* Refine the given value(s) again, assuming the constraint holds.
*
* @return the constrained values, without performing constraint checks.
* @see [[assume]], [[assumeFurther]].
*/
inline def assumeAllFurther[C2]: F[A :| (C1 & C2)] = wrapper.asInstanceOf[F[A :| (C1 & C2)]]

/**
* Refine the given value(s) again at runtime.
*
* @param constraint the new constraint to test.
* @return the given values refined with `C1 & C2`.
* @throws IllegalArgumentException if the constraint is not satisfied.
* @see [[refineUnsafe]], [[refineFurtherUnsafe]].
*/
inline def refineAllFurtherUnsafe[C2](using mapLogic: MapLogic[F], inline constraint: Constraint[A, C2]): F[A :| (C1 & C2)] =
mapLogic.map(wrapper, _.refineFurtherUnsafe[C2])

/**
* Refine the given value(s) again at runtime, resulting in an [[Either]].
*
* @param constraint the new constraint to test.
* @return a [[Right]] containing the given values refined with `C1 & C2` or a [[Left]] containing the constraint message.
* @see [[refineEither]], [[refineAllFurtherEither]].
*/
inline def refineAllFurtherEither[C2](using mapLogic: MapLogic[F], inline constraint: Constraint[A, C2]): Either[String, F[A :| (C1 & C2)]] =
boundary:
Right(mapLogic.map(
wrapper,
_.refineFurtherEither[C2] match
case Right(value) => value
case Left(error) => break(Left(error))
))

/**
* Refine the given value(s) again at runtime, resulting in an [[Option]].
*
* @param constraint the new constraint to test.
* @return a [[Option]] containing the given values refined with `C1 & C2` or [[None]].
* @see [[refineOption]], [[refineFurtherOption]].
*/
inline def refineAllFurtherOption[C2](using mapLogic: MapLogic[F], inline constraint: Constraint[A, C2]): Option[F[A :| (C1 & C2)]] =
boundary:
Some(mapLogic.map(
wrapper,
_.refineFurtherOption[C2] match
case Some(value) => value
case None => break(None)
))

extension [A, C1, C2](value: A :| C1 :| C2)

/**
Expand Down
65 changes: 60 additions & 5 deletions main/src/io/github/iltotore/iron/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import io.github.iltotore.iron.macros
import scala.Console.{CYAN, RESET}
import scala.compiletime.{codeOf, error, summonInline}
import scala.reflect.TypeTest
import scala.util.NotGiven
import scala.util.{boundary, NotGiven}
import scala.util.boundary.break

/**
* The main package of Iron. Contains:
Expand Down Expand Up @@ -48,11 +49,10 @@ end IronType
extension [A](value: A)

/**
* Refine the given value at runtime, assuming the constraint holds.
* Refine the given value, assuming the constraint holds.
*
* @param constraint the constraint to test with the value to refine.
* @return a constrained value, without performing constraint checks.
* @see [[autoRefine]], [[refineUnsafe]].
* @see [[assumeAll]], [[autoRefine]], [[refineUnsafe]].
*/
inline def assume[B]: A :| B = value

Expand Down Expand Up @@ -98,4 +98,59 @@ extension [A](value: A)
* @see [[autoRefine]], [[refineUnsafe]], [[refineEither]].
*/
inline def refineOption[B](using inline constraint: Constraint[A, B]): Option[A :| B] =
Option.when(constraint.test(value))(value)
Option.when(constraint.test(value))(value)

extension [F[_], A](wrapper: F[A])

/**
* Refine the contained value(s), assuming the constraint holds.
*
* @return constrained values, without performing constraint checks.
* @see [[assume]], [[autoRefine]], [[refineUnsafe]].
*/
inline def assumeAll[B]: F[A :| B] = wrapper

/**
* Refine the given value(s) at runtime.
*
* @param constraint the constraint to test with the value to refine.
* @return the given values as [[IronType]].
* @throws an [[IllegalArgumentException]] if the constraint is not satisfied.
* @see [[refineUnsafe]].
*/
inline def refineAllUnsafe[B](using mapLogic: MapLogic[F], inline constraint: Constraint[A, B]): F[A :| B] =
mapLogic.map(wrapper, _.refineUnsafe[B])


/**
* Refine the given value(s) at runtime, resulting in an [[Either]].
*
* @param constraint the constraint to test with the value to refine.
* @return a [[Right]] containing the given values as [[IronType]] or a [[Left]] containing the constraint message.
* @see [[refineEither]].
*/
inline def refineAllEither[B](using mapLogic: MapLogic[F], inline constraint: Constraint[A, B]): Either[String, F[A :| B]] =
boundary:
Right(mapLogic.map(
wrapper,
_.refineEither[B] match
case Right(value) => value
case Left(error) => break(Left(error))
))

/**
* Refine the given value(s) at runtime, resulting in an [[Option]].
*
* @param constraint the constraint to test with the value to refine.
* @return a [[Some]] containing the given values as [[IronType]] or [[None]].
* @see [[refineOption]].
*/
inline def refineAllOption[B](using mapLogic: MapLogic[F], inline constraint: Constraint[A, B]): Option[F[A :| B]] =
boundary:
Some(mapLogic.map(
wrapper,
_.refineOption[B] match
case Some(value) => value
case None => break(None)
))

Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ object RefinedTypeOpsSuite extends TestSuite:
assert(t2 == Temperature(15.0))
}

test("assume") - assert(Temperature.assume(-15) == -15.0.asInstanceOf[Temperature])

test("applyUnsafe") {
test - assertMatch(Try(Temperature.applyUnsafe(-100))) { case Failure(e) if e.getMessage == "Should be strictly positive" => }
test - assert(Temperature.applyUnsafe(100) == Temperature(100))
}

test("either") {
val eitherWithFailingPredicate = Temperature.either(-5.0)
assert(eitherWithFailingPredicate == Left("Should be strictly positive"))
Expand All @@ -46,12 +53,22 @@ object RefinedTypeOpsSuite extends TestSuite:
assert(fromWithSucceedingPredicate.contains(Temperature(100)))
}

test("applyUnsafe") {
test - assertMatch(Try(Temperature.applyUnsafe(-100))) { case Failure(e) if e.getMessage == "Should be strictly positive" => }
test - assert(Temperature.applyUnsafe(100) == Temperature(100))
test("assumeAll") - assert(Temperature.assumeAll(List(1, -15)) == List(1, -15).asInstanceOf[List[Temperature]])

test("applyAllUnsafe") {
test - assertMatch(Try(Temperature.applyAllUnsafe(List(1, 2, -3)))) { case Failure(e) if e.getMessage == "Should be strictly positive" => }
test - assert(Temperature.applyAllUnsafe(List(1, 2, 3)) == List(Temperature(1), Temperature(2), Temperature(3)))
}

test("assume") - assert(Temperature.assume(-15) == -15.0.asInstanceOf[Temperature])
test("either") {
test - assert(Temperature.eitherAll(List(1, 2, -3)) == Left("Should be strictly positive"))
test - assert(Temperature.eitherAll(List(1, 2, 3)) == Right(List(Temperature(1), Temperature(2), Temperature(3))))
}

test("option") {
test - assert(Temperature.optionAll(List(1, 2, -3)).isEmpty)
test - assert(Temperature.optionAll(List(1, 2, 3)).contains(List(Temperature(1), Temperature(2), Temperature(3))))
}

test("nonOpaque") {
val moisture = Moisture(11)
Expand Down
Loading

0 comments on commit 50b3fb3

Please sign in to comment.