cats-eo

Iron integration

The cats-eo-iron module turns Iron refined types into optics. Iron checks a refinement either at compile time (a literal is proven to satisfy the constraint) or at runtime (a value that arrives as data is tested). The runtime kind is a partial conversion A ⇀ A :| C with a total way back — and that is exactly a Prism.

libraryDependencies += "dev.constructive" %% "cats-eo-iron" % "0.18"

No carrier and no givens ship in this module: IronPrism builds the stock core MendTearPrism, so every fused composition (Lens → Optional, Traversal → filtering Traversal) and every capability applies unchanged.

IronPrism

IronPrism[A, C] needs Iron's RuntimeConstraint[A, C], which Iron derives from any Constraint wherever the types are concrete — so every built-in constraint and every one you define qualifies.

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.Positive

import dev.constructive.eo.iron.IronPrism
import dev.constructive.eo.optics.{Lens, Traversal}
import dev.constructive.eo.optics.Optic.*

val positive = IronPrism[Int, Positive]

getOption tests the constraint, reverseGet forgets it:

positive.getOption(42)
// res0: Option[IronType[Int, Positive]] = Some(42)
positive.getOption(-1)
// res1: Option[IronType[Int, Positive]] = None
positive.reverseGet(7.refineUnsafe[Positive])
// res2: Int = 7

Both Prism laws hold for any pure constraint: a value that passed the test passes it again, and forgetting a refinement gives back the very value that was refined. The module's spec runs cats-eo-laws' PrismTests on every constructor.

From a constraint instance

When you hold the constraint itself — a RuntimeConstraint[A, C], or one of Iron's Constraint givens — .prism gives the same Prism:

import io.github.iltotore.iron.constraint.string.Alphanumeric
import dev.constructive.eo.iron.prism

val positiveInt: RuntimeConstraint[Int, Positive] = summon
val alphanumeric = summon[Constraint[String, Alphanumeric]].prism
positiveInt.prism.getOption(-1)
// res3: Option[IronType[Int, DescribedAs[Greater[0], "Should be strictly positive"]]] = None
alphanumeric.getOption("abc123")
// res4: Option[IronType[String, DescribedAs[ForAll[Digit | Letter], "Should be alphanumeric"]]] = Some(
//   "abc123"
// )
alphanumeric.getOption("not ok!")
// res5: Option[IronType[String, DescribedAs[ForAll[Digit | Letter], "Should be alphanumeric"]]] = None

The Constraint form is inline, like Iron's own RuntimeConstraint.derived, so its receiver must be a statically known instance such as a summon.

Composing

A refinement is usually a property of one field. Compose it after the Lens that reaches the field and you get an Optional that writes only when the field is valid:

final case class Person(name: String, age: Int)

val ageL = Lens[Person, Int](_.age, (p, a) => p.copy(age = a))
val validAge = ageL.andThen(positive)
validAge.getOption(Person("Ada", 36))
// res6: Option[IronType[Int, Positive]] = Some(36)
validAge.modify(a => (a + 1).refineUnsafe[Positive])(Person("Ada", 36))
// res7: Person = Person(name = "Ada", age = 37)
validAge.modify(a => (a + 1).refineUnsafe[Positive])(Person("Bob", -1))
// res8: Person = Person(name = "Bob", age = -1)

After a Traversal the prism is a filter: only the elements that satisfy the constraint are visited, the rest pass through untouched.

Traversal.each[List, Int].andThen(positive).foldMap(List(_))(List(3, -1, 4, 0))
// res9: List[IronType[Int, Positive]] = List(3, 4)

Like every Prism, a miss is a silent pass-through on write. When the reason for a miss matters, Iron's own refineEither carries the constraint's message.

Refined newtypes

Iron's RefinedType / RefinedSubtype companions define opaque newtypes over a refinement. IronPrism.refined[T] builds the prism from the base type to the newtype:

type Age = Age.T
object Age extends RefinedType[Int, Positive]

val ageP = IronPrism.refined[Age]
ageP.getOption(30)
// res10: Option[Age] = Some(30)
ageP.getOption(0)
// res11: Option[Age] = None

With import dev.constructive.eo.iron.prism (above), the companion itself gives the same Prism:

Age.prism.getOption(30)
// res12: Option[T] = Some(30)

Whole containers

IronPrism.all[F, A, C] is all-or-nothing over any Foldable: it hits only when every element satisfies the constraint, and then refines the container as it stands — no copy, no per-element allocation, since A :| C is an opaque alias of A.

IronPrism.all[List, Int, Positive].getOption(List(1, 2, 3))
// res13: Option[List[IronType[Int, Positive]]] = Some(List(1, 2, 3))
IronPrism.all[List, Int, Positive].getOption(List(1, -2, 3))
// res14: Option[List[IronType[Int, Positive]]] = None

To keep only the valid elements of a mixed container instead, compose Traversal.each with IronPrism as above.