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: Support first order types #222

Merged
merged 5 commits into from
Feb 15, 2024
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
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