The spectroscopy
library extends the Monocle library of optics with an
optic-like type which we call a Scope. A Scope is similar to an Optional (also
known as an affine Traversal), but with a stronger set
method called put
.
"Scope" is short for Spectroscope, so-named because Spectroscopes can be formed
by composing a Lens with a Prism.
A Scope is optic-like because it defines getter and setter methods which follow laws similar to those of many optics, but unlike standard optics Scopes have some restrictions on composability.
Alongside Scopes we introduce an error-reporting extension of the Prism type, called an EPrism (also known as a coindexed Prism). EPrisms behave as Prisms, except that the getter method will return an error message when the getter method fails to match a value. This can be used to explain why the value didn't match. Built upon EPrisms are also EScopes, which extend Scopes with similar error-reporting behaviour to EPrisms.
Composing a Lens with a Prism in the usual manner results in an Optional. An
Optional[S, A]
may be defined by a getter getOption(s: S): Option[A]
and a
setter set(a: A)(s: S): A
that satisfies the following laws for every s: S
,
a: A
, and b: B
:
-
You get back what you put in (if and only if there was already a value in there):
getOption(set(a)(s)) <==> getOption(s).map(_ => a)
-
Putting back what you got doesn't change anything:
getOrModify(s).fold(identity, set(_)(s)) <==> s
-
Setting twice is the same as setting once:
set(a)(set(b)(s)) <==> set(a)(s)
A Scope may also be composed from a Lens and a Prism. A Scope[S, A]
may be
defined by a getter getOption(s: S)
and a setter put(a: A)(s: S): A
that
satisfies the following laws for every s: S
, a: A
, b: B
:
-
You get back what you put in (always!):
getOption(put(a)(s)) <==> Some(a)
-
Putting back what you got doesn't change anything:
getOrModify(s).fold(identity, put(_)(s)) <==> s
-
Setting twice is the same as setting once:
put(a)(put(b)(s)) <==> put(a)(s)
The difference between the set
method of an Optional and the put
method of
a Scope is in the first law. A set
followed by a getOption
will only return
a value if getOption
initially returned a value, i.e. set
only replaces
existing values, whereas a put
followed by a getOption
will always return
a value, i.e. put
will add a value if it doesn't already exist.
A Prism[S, A]
may be defined by a getter getOption(s: S): Option[A]
and a
setter reverseGet(a: A): S
that satisfies the following laws for every s: S
, a: A
:
-
You get back what you put in:
prism.getOption(prism.reverseGet(a)) <==> Some(a)
-
Putting back what you got doesn't change anything:
prism.getOrModify(s).fold(identity, prism.reverseGet) <==> s
An EPrism[E, S, A]
may be defined by a getter getOrError(s: S): E \/ A
and
a setter reverseGet(a: A): S
that satisfies the Prism laws if we define def getOption(s: S): Option[A] = getOrError(s).toOption
. The laws therefore say
nothing about the contents of the error messages generated by getOrError
,
only about when an error message is generated. So we can also define an
EPrism[Unit, S, A]
for any Prism[S, A]
by defining def getOrError(s: S): E \/ A = getOption(s).fold(-\/(()))(\/.right)
.
Similar to EPrisms vs. Prisms, an EScope[E, S, A]
may be defined by a getter
getOrError(s: S): E \/ A
and a setter put(a: A)(s: S): S
that satisfies the
Scope laws if we define get getOption(s: S): Option[A] = getOrError(s).toOption
.
If Lenses are for dealing with product types and Prisms are for dealing with sum types, then Scopes are for dealing with product types containing sum types.
val eitherMap = Map(
"foo" -> Left("Apple"),
"bar" -> Right(42)
)
We can compose Lenses and Prisms into Optionals in order to access values
inside eitherMap
:
import monocle.std.either._
import monocle.std.map._
import monocle.std.option._
val eitherAtMap = atMap[String, Either[String, Int]]
val _SomeLeftAtFoo = eitherAtMap.at("foo") composePrism some composePrism stdLeft
val _SomeLeftAtBar = eitherAtMap.at("bar") composePrism some composePrism stdLeft
val _SomeLeftAtBaz = eitherAtMap.at("baz") composePrism some composePrism stdLeft
val _NoneAtFoo = eitherAtMap.at("foo") composePrism none
val _NoneAtBaz = eitherAtMap.at("baz") composePrism none
The Optionals can get values from the eitherMap
:
scala> _SomeLeftAtFoo.getOption(eitherMap)
res: Option[String] = Some(Apple)
scala> _SomeLeftAtBar.getOption(eitherMap)
res: Option[String] = None
scala> _SomeLeftAtBaz.getOption(eitherMap)
res: Option[String] = None
scala> _NoneAtFoo.getOption(eitherMap)
res: Option[Unit] = None
scala> _NoneAtBaz.getOption(eitherMap)
res: Option[Unit] = Some(())
The Optionals can also set
values inside the eitherMap
:
scala> _SomeLeftAtFoo.set("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Banana), bar -> Right(42))
scala> _SomeLeftAtBar.set("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
scala> _SomeLeftAtBaz.set("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
scala> _NoneAtFoo.set(())(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
scala> _NoneAtBaz.set(())(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
However the Optionals will only successfully set a value at a key of the
eitherMap
if the existing value matches the Prism composed in the Optional.
For our use case the desired behaviour was to replace the value at the key of
the eitherMap
with the result of calling reverseGet
using the specified
Prism. We encapsulated this behaviour into a Scope. Composing Scopes from
Lenses and Prisms is similar to composing Optionals:
import au.com.cba.omnia.spectroscopy._
val _SomeLeftAtFoo_ = eitherAtMap.at("foo") composePrismAsScope some composePrism stdLeft
val _SomeLeftAtBar_ = eitherAtMap.at("bar") composePrismAsScope some composePrism stdLeft
val _SomeLeftAtBaz_ = eitherAtMap.at("baz") composePrismAsScope some composePrism stdLeft
val _NoneAtFoo_ = eitherAtMap.at("foo") composePrismAsScope none
val _NoneAtBaz_ = eitherAtMap.at("baz") composePrismAsScope none
The get
method behaves identically to the get
method from Optional:
scala> _SomeLeftAtFoo_.getOption(eitherMap)
res: Option[String] = Some(Apple)
scala> _SomeLeftAtBar_.getOption(eitherMap)
res: Option[String] = None
scala> _SomeLeftAtBaz_.getOption(eitherMap)
res: Option[String] = None
scala> _NoneAtFoo_.getOption(eitherMap)
res: Option[Unit] = None
scala> _NoneAtBaz_.getOption(eitherMap)
res: Option[Unit] = Some(())
However compared to the set
method of the Optional, the put
method always
succeeds:
scala> _SomeLeftAtFoo_.put("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Banana), bar -> Right(42))
scala> _SomeLeftAtBar_.put("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Left(Banana))
scala> _SomeLeftAtBaz_.put("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42), baz -> Left(Banana))
scala> _NoneAtFoo_.put(())(eitherMap)
res: Map[String,Either[String,Int]] = Map(bar -> Right(42))
scala> _NoneAtBaz_.put(())(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
We can also call set
on a Scope to get the same behaviour as an Optional:
scala> _SomeLeftAtFoo_.set("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Banana), bar -> Right(42))
scala> _SomeLeftAtBar_.set("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
scala> _SomeLeftAtBaz_.set("Banana")(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
scala> _NoneAtFoo_.set(())(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
scala> _NoneAtBaz_.set(())(eitherMap)
res: Map[String,Either[String,Int]] = Map(foo -> Left(Apple), bar -> Right(42))
A Prism has a getter method that can fail, but sometimes we'd like to know
why the getter method failed, for the purposes of error-reporting. In the
example above we were interested in getting the String
out of a
Option[Either[String, Int]]
in a Map[String, Either[String, Int]]
. Suppose
that we'd like to log instances where a key has value with an unexpected type,
but don't want to log instances where a key is merely missing. We can do this
in a composable manner using EPrisms.
def _eLeft[A, B] = stdLeft[A, B].asEPrism.mapError(Function.const("Expected a Left, but found a Right"))
def _eSome[A] = some[A].asEPrism.mapError(Function.const("Expected a Some, but found a None"))
def _eSomeLeft[A, B] = _eSome[Either[A, B]] composeEPrismRight _eLeft
scala> _eLeft[String, Int].getOrError(Right(42))
res: scalaz.\/[String,String] = -\/(Expected a Left, but found a Right)
scala> _eSome[Either[String, Int]].getOrError(Some(Left("Apple")))
res: scalaz.\/[String,Either[String,Int]] = \/-(Left(Apple))
scala> _eSome[Either[String, Int]].getOrError(None)
res: scalaz.\/[String,Either[String,Int]] = -\/(Expected a Some, but found a None)
scala> _eSomeLeft[String, Int].getOrError(Some(Left("Apple")))
res: scalaz.\/[Option[String],String] = \/-(Apple)
scala> _eSomeLeft[String, Int].getOrError(Some(Right(42)))
res: scalaz.\/[Option[String],String] = -\/(Some(Expected a Left, but found a Right))
scala> _eSomeLeft[String, Int].getOrError(None)
res: scalaz.\/[Option[String],String] = -\/(None)
We can also compose Lenses and EPrisms into EScopes, to give error-reporting for the example above.
val _eSomeLeftAtFoo = eitherAtMap.at("foo") composeEPrismAsEScope _eSomeLeft
val _eSomeLeftAtBar = eitherAtMap.at("bar") composeEPrismAsEScope _eSomeLeft
val _eSomeLeftAtBaz = eitherAtMap.at("baz") composeEPrismAsEScope _eSomeLeft
scala> _eSomeLeftAtFoo.getOrError(eitherMap)
res: scalaz.\/[Option[String],String] = \/-(Apple)
scala> _eSomeLeftAtBar.getOrError(eitherMap)
res: scalaz.\/[Option[String],String] = -\/(Some(Expected a Left, but found a Right))
scala> _eSomeLeftAtBaz.getOrError(eitherMap)
res: scalaz.\/[Option[String],String] = -\/(None)
Scopes compose on the left with Lenses and on the right with Prisms to form new Scopes. Unlike the standard optics, Scopes do not generally compose with other Scopes to form new Scopes. This is due to the strengthened requirements of the setter method compared to an Optional.
To show that Scopes do not generally compose, we provide an example of two Scopes that cannot compose.
def _Left[A] = PScope[A, A, Nothing, Nothing](
s => -\/(s)
)(
b => s => ???
)
def _Right[B] = PScope[Nothing, Nothing, B, B](
s => ???
)(
b => s => ???
)
For all types A
and B
we note that _Left[A]
and _Right[B]
satisfy the
Scope laws. In the case of _Left[A]
, Scope laws (1) and (3) are trivially
satisfied because they are universally quantified over the values inhabited by
the type Nothing
, and Scope law (2) is satisfied by always returning
-\/(s)
. In the case of _Right[B]
, all of the Scope laws are universally
quantified over the values in Nothing
, and so all of the Scope laws are
trivially satisfied.
We will show that any well-typed composeScope
method will result in an object
that doesn't satisfy the Scope laws when applied to specific instances of
_Left[A]
and _Right[B]
. Let composeScope
be a method defined on the
abstract class PScope[S, T, A, B]
with the type signature
composeScope[U, V](other: PScope[A, B, U, V]): PScope[S, T, U, V]
,
let A
and B
be inhabited types, and
let val _LeftRight = _Left[A] composeScope _Right[B]
.
Then _LeftRight.getOption
has the type signature
_LeftRight.getOption(s: A): Option[B]
. As the method composeScope
is defined on the abstract class PScope[S, T, A, B]
, it cannot itself create
a value of type B
, it can only get a value of type B
by
calling methods from _Left[A]
or _Right[B]
. The methods of
_Left[A]
do not return a B
due to their type signatures. The methods
of _Right[B]
cannot be called because their type signatures require
arguments of type Nothing, hence these methods do not return a B
.
So for every s: A
we have _LeftRight.getOption(s) == None
, and thus for
every a: A
we have getOption(put(a)(s)) == None != Some(a)
. Therefore
_LeftRight
doesn't satisfy the Scope laws.