Constraint

In Iron, a constraint consist of a type, called "dummy" type, associated with a given instance of Constraint.

See Refinement for usage.

Operations

Usually, you can make your constraint out of existing ones. Iron provides several operators to help you to compose them.

Union and intersection

Type union C1 | C2 and intersection C1 & C2 respectively act as a boolean OR/AND in Iron. For example, GreaterEqual is just a union of Greater and StrictEqual:

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

type GreaterEqual[V] = Greater[V] | StrictEqual[V]
GreaterEqual.scala
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

type GreaterEqual[V] = Greater[V] | StrictEqual[V]

val x: Int :| GreaterEqual[0] = 1 //OK
val y: Int :| GreaterEqual[0] = -1 //Compile-time error: (Should be greater than 0 | Should strictly equal to 0)

Same goes for intersection:

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

type GreaterEqual[V] = Greater[V] | StrictEqual[V]

type Between[Min, Max] = GreaterEqual[Min] & LessEqual[Max]

Other operations

Most constraint operators provided by Iron are "normal" constraints taking another constraint as parameter.

Here is a list of the most used operators:

  • Not[C]: like a boolean "not". Negate the result of the C constraint.
  • DescribedAs[C, V]: attach a custom description V to C.
  • ForAll[C]: check if the C constraint passes for all elements of a collection/String
  • Exists[C]: check if the C constraint passes for at least one element of a collection/String

Dummy type

Usually, the dummy type is represented by a final class. Note that this class (or whatever entity you choose as a dummy) should not have constructor parameters.

final class Positive
Positive.scala

The dummy type does nothing in itself. It is only used by the type system/implicit search to select the right Constraint.

Constraint implementation

Each refined type A :| C need an implicit instance of Constraint[A, C] to be verified. For instance, Int :| Positive need a given instance of Constraint[Int, Positive].

Here is how it looks:

final class Positive

import io.github.iltotore.iron.*

given Constraint[Int, Positive] with

  override inline def test(value: Int): Boolean = value > 0

  override inline def message: String = "Should be strictly positive"
PositiveAndConstraint.scala

Note that you need to do this for each type. If your constraint supports multiple types (e.g numeric types), you can use a trait to reduce boilerplate:

final class Positive

import io.github.iltotore.iron.*

trait PositiveConstraint[A] extends Constraint[A, Positive]:
  override inline def message: String = "Should be strictly positive"

given PositiveConstraint[Int] with
  override inline def test(value: Int): Boolean = value > 0

given PositiveConstraint[Double] with
  override inline def test(value: Double): Boolean = value > 0.0

This constraint can now be used like any other:

final class Positive

import io.github.iltotore.iron.*

given Constraint[Int, Positive] with

  override inline def test(value: Int): Boolean = value > 0

  override inline def message: String = "Should be strictly positive"

val x: Int :| Positive = 1
val y: Int :| Positive = -1 //Compile-time error: Should be strictly positive

Constraint parameters

You can parameterize your constraints. Let's take the standard Greater constraint.

Constraint parameters are held by the dummy type as type parameters, not constructor parameters.

final class Greater[V]
Greater.scala

Then, we can get the value of the passed type using scala.compiletime.constValue:

final class Greater[V]

import io.github.iltotore.iron.*
import scala.compiletime.constValue

given [V]: Constraint[Int, Greater[V]] with

  override inline def test(value: Int): Boolean = value > constValue[V]

  override inline def message: String = "Should be greater than " + stringValue[V]
GreaterAndConstraint.scala

Note that we're using stringValue in the message method to get a fully inlined String value of the given type because String#toString is not inlined. This method is equivalent to constValue[scala.compiletime.ops.any.ToString[V]].

Now testing the constraint:

final class Greater[V]

import io.github.iltotore.iron.*
import scala.compiletime.constValue

given [V]: Constraint[Int, Greater[V]] with

  override inline def test(value: Int): Boolean = value > constValue[V]

  override inline def message: String = "Should be greater than " + stringValue[V]

val x: Int :| Greater[5] = 6
val y: Int :| Greater[5] = 3 //Compile-time error: Should be greater than 5

Runtime proxy

Iron provides a proxy for Constraint, named RuntimeConstraint. It is used the same way as Constraint:

def refineRuntimeOption[A, C](value: A)(using constraint: RuntimeConstraint[A, C]): Option[A :| C] =
  Option.when(constraint.test(value))(value.asInstanceOf[A :| C])

refineRuntimeOption[Int, Positive](5) //Some(5)
refineRuntimeOption[Int, Positive](-5) //None

with two advantages:

  • It does not need the summoning method (here refineOption) to be inline
  • It significantly lowers the generated bytecode and usually improves performances

Therefore, it is recommended to use RuntimeConstraint instead of Constraint when using the instance at runtime. For example, most of RefinedTypeOps's (see New types) methods use a RuntimeConstraint.

It is also recommended to use RuntimeConstraint to derive typeclasses, especially when using a given with a function value.

trait FromString[A]:

  def fromString(text: String): Either[String, A]

given [A, C](using constraint: RuntimeConstraint[A, C], instanceA: FromString[A]): FromString[A :| C] = text =>
  instanceA
    .fromString(text)
    .filterOrElse(constraint.test(_), constraint.message)
    .map(_.asInstanceOf[A :| C])

Note that using a Constraint here (and having to our given instance inline) will produce a warning:

An inline given alias with a function value as right-hand side can significantly increase generated code size. You should either drop the inline or rewrite the given with an explicit apply method.

RuntimeConstraint is also useful when you need to reuse the same constraint. Here is an example from RefinedTypeOps:

trait RefinedTypeOps[A, C, T]:

  inline def rtc: RuntimeConstraint[A, C] = ???

  def option(value: A): Option[T] =
    Option.when(rtc.test(value))(value.asInstanceOf[T])

Pre-defined constraints

Iron provides a set of pre-defined constraints in the io.github.iltotore.iron.constraint package. You can find them in the API documentation.

Global constraints

Some constraints are available for all types. They are located in the io.github.iltotore.iron.constraint.any object.

Here is a list of the most used ones:

  • StrictEqual: check if a value is equal to a given one.
  • Not: a constraint decorator to negate another constraint. The ! alias is also available.
  • DescribedAs: attach a custom description to a constraint.
  • True: an always-true constraint.
  • False: an always-false constraint.
  • Xor: a boolean XOR between two constraints.
  • In: check if a value is in a given value tuple.

Char constraints

Some constraints are available for Char types. They are located in the io.github.iltotore.iron.constraint.char object.

Here is a list of the most used ones:

  • Digit: check if a character is a digit.
  • Letter: check if a character is a letter.
  • LowerCase: check if a character is a lower case character.
  • UpperCase: check if a character is an upper case character.
  • Whitespace: check if a character is a whitespace character.
  • Special: check if a character is a special character (i.e. not a digit nor a letter).

Numeric constraints

Some constraints are available for numeric types. They are located in the io.github.iltotore.iron.constraint.numeric object.

Here is a list of the most used ones:

  • Less: check if a value is less than a given one.
  • Greater: check if a value is greater than a given one.
  • LessEqual: check if a value is less than or equal to a given one.
  • GreaterEqual: check if a value is greater than or equal to a given one.
  • Positive: check if a value is strictly positive.
  • Negative: check if a value is strictly negative.
  • Positive0: check if a value is positive or zero.
  • Negative0: check if a value is negative or zero.
  • Interval.Closed: check if a value is in a closed interval.
  • Interval.Open: check if a value is in an open interval.
  • Interval.OpenClosed: check if a value is in an open-closed interval.
  • Interval.ClosedOpen: check if a value is in a closed-open interval.
  • Infinity: check if a value is infinite (positive or negative).
  • NaN: check if a value is not a representable number.
  • Multiple: check if a value is a multiple of another one.
  • Divide: check if a value is a divisor of another one.
  • Odd: check if a value is odd.
  • Even: check if a value is even.

Collection constraints

Some constraints are available for collections. They are located in the io.github.iltotore.iron.constraint.collection object.

Here is a list of the most used ones:

  • ForAll: check if a constraint passes for all elements of a collection.
  • Exists: check if a constraint passes for at least one element of a collection.
  • Length: check if the collection length satisfies a given constraint.
  • Empty: check if a collection is empty.
  • FixedLength: check if a collection has a fixed length.
  • MinLength: check if a collection has a minimum length.
  • MaxLength: check if a collection has a maximum length.
  • Contains: check if a collection contains a given element.
  • Head: check if a collection's head satisfies a given constraint.
  • Last: check if a collection's last element satisfies a given constraint.
  • Tail: check if a collection's tail satisfies a given constraint.
  • Init: check if a collection's init satisfies a given constraint.

String constraints

Some constraints are available for String types. They are located in the io.github.iltotore.iron.constraint.string object. Note that, as String is an Iterable[Char], you can use the collection constraints on String.

Here is a list of the most used ones:

  • Blank: check if a string is blank (i.e. empty or only containing whitespaces).
  • StartWith: check if a string starts with a given prefix.
  • EndWith: check if a string ends with a given suffix.
  • Match: check if a string matches a given regular expression.
  • Alphanumeric: check if a string contains only alphanumeric characters.
  • LettersLowerCase: check if all letters of a string are lower-cased letters.
  • LettersUpperCase: check if all letters of a string are upper-cased letters.
  • Trimmed: check if a string is trimmed (i.e. without leading and trailing whitespaces).
  • ValidUUID: check if a string is a valid UUID.
  • ValidURL: check if a string is a valid URL.
  • SemanticVersion: check if a string is a valid semantic version as defined on semver.org.