cats-eo

Indexed: Grates and Glasses

Use Indexed when a focus has a fixed index space: settings for every environment, readings at every channel, or a grid formed by nesting those shapes. Reads address a coordinate; writes supply a value for every coordinate. This is different from a traversal over a variable number of elements.

The optic lives in dev.constructive.eo.optics.Indexed. Its carrier, defined in data/Glass.scala, is the named tuple:

type GlassF[I] = [X, A] =>> (context: X, values: I => A)

Optic.to decomposes a source into residual context X and a tabulation I => A; Optic.from reconstructs the target from context and new values. An indexed Glass may retain source metadata in X. A Grate needs no source leftover: Indexed.Grate[S, T, A, B, I] specializes X = Unit. Indexed.Aux[S, T, A, B, I, X0] exposes a particular residual type.

Environment-dependent settings

A Boolean reader is a small, useful product: false is staging and true is production. Rather than choose one environment, a Grate can rewrite the settings for both. Cats' Representable provides inverse index and tabulate operations. Pass an explicit Representable.Aux witness so the concrete index remains Boolean, not an abstract Representation.

import cats.Representable
import cats.instances.function.given
import dev.constructive.eo.optics.Indexed
import dev.constructive.eo.data.GlassF

type ByEnvironment[A] = Boolean => A

val environments: Representable.Aux[ByEnvironment, Boolean] =
  summon[Representable.Aux[ByEnvironment, Boolean]]

val settings: Indexed.Grate[
  ByEnvironment[Int], ByEnvironment[Int], Int, Int, Boolean
] = Indexed.representable[ByEnvironment, Int, Int](environments)

val timeout: ByEnvironment[Int] = production => if production then 30 else 5

at reads one coordinate. Indexed modify supplies both the index and the old value; replace broadcasts to the entire index space.

val tuned = settings.modify((production, seconds) =>
  if production then seconds + 10 else seconds
)(timeout)
val maintenance = settings.replace(60)(timeout)
settings.at(true)(timeout)
// res0: Int = 30
(tuned(false), tuned(true))
// res1: Tuple2[Int, Int] = (5, 40)
(maintenance(false), maintenance(true))
// res2: Tuple2[Int, Int] = (60, 60)

The target need not have the same element type. A type-changing write can render the whole product as user-facing settings:

val renderSettings = Indexed.representable[ByEnvironment, Int, String](environments)
val rendered = renderSettings.modify((production, seconds) =>
  s"${if production then "production" else "staging"}: ${seconds}s"
)(timeout)
require(rendered(false) == "staging: 5s")
require(rendered(true) == "production: 30s")
val disabled = renderSettings.replace("disabled")(timeout)
(rendered(false), rendered(true))
// res5: Tuple2[String, String] = ("staging: 5s", "production: 30s")
(disabled(false), disabled(true))
// res6: Tuple2[String, String] = ("disabled", "disabled")

With X = Unit, reconstruction does not need a previous source:

val fresh = settings.from((context = (), values = (production: Boolean) =>
  if production then 45 else 10
))
(fresh(false), fresh(true))
// res7: Tuple2[Int, Int] = (10, 45)

Nested settings: the full grid

Now each environment contains two channel settings, ordinary (false) and priority (true). Nesting two Boolean readers creates four coordinates, not just the two where the Boolean indexes agree.

val rows = Indexed.representable[
  ByEnvironment, ByEnvironment[Int], ByEnvironment[Int]
](environments)
val grid = rows.andThen(settings)

val timeouts: ByEnvironment[ByEnvironment[Int]] = production => priority =>
  (if production then 30 else 5) + (if priority then 2 else 0)

def gridValues(g: ByEnvironment[ByEnvironment[Int]]): (Int, Int, Int, Int) =
  (g(false)(false), g(false)(true), g(true)(false), g(true)(true))

The dedicated andThen takes the product index (Boolean, Boolean). An off-diagonal update can therefore reach staging's priority channel:

val priorityStaging = grid.modify((coordinate, seconds) =>
  if coordinate == (false, true) then seconds + 100 else seconds
)(timeouts)
val allMaintenance = grid.replace(60)(timeouts)
require(gridValues(priorityStaging) == (5, 107, 30, 32))
require(gridValues(allMaintenance) == (60, 60, 60, 60))
grid.at((false, true))(timeouts)
// res10: Int = 7
gridValues(priorityStaging)
// res11: Tuple4[Int, Int, Int, Int] = (5, 107, 30, 32)
gridValues(allMaintenance)
// res12: Tuple4[Int, Int, Int, Int] = (60, 60, 60, 60)

The equality test is this application's choice for Boolean coordinates, not a requirement of Indexed. at is a read, not a one-coordinate setter: arbitrary index types need not supply coordinate equality.

Generic composition retains residual context (outer.X, I => inner.X). Even though both components here are Grates, the composite's X is (Unit, Boolean => Unit), not literally Unit. Unit-context normalization is still open; do not annotate this result as Indexed.Grate. A third level similarly forms nested product indexes, with reassociation needed to compare the two composition groupings.

Labelled readings: a context-bearing Glass

A labelled pair of readings has the same two-coordinate focus, but now reconstruction must also retain a label. A Glass separates that metadata from the values being edited:

case class Labelled[A](label: String, readings: Boolean => A)

def labelled[A, B]: Indexed.Aux[
  Labelled[A], Labelled[B], A, B, Boolean, String
] =
  Indexed.apply[Labelled[A], Labelled[B], A, B, Boolean, String](
    source => (source.label, source.readings)
  )((label, values) => Labelled[B](label, values))

val readings = labelled[Int, Int]
val sensor = Labelled[Int]("boiler", high => if high then 80 else 20)

Both indexed modification and broadcast replacement preserve the label:

val calibrated = readings.modify((high, value) =>
  value + (if high then 2 else 1)
)(sensor)
val reset = readings.replace(0)(sensor)
require(calibrated.label == sensor.label)
require(reset.label == sensor.label)
(calibrated.label, calibrated.readings(false), calibrated.readings(true))
// res15: Tuple3[String, Int, Int] = ("boiler", 21, 82)
(reset.label, reset.readings(false), reset.readings(true))
// res16: Tuple3[String, Int, Int] = ("boiler", 0, 0)

from accepts arbitrary context/values bundles, not only ones obtained from to. The carrier's named fields make the contract explicit:

val supplied: GlassF[Boolean][String, Int] =
  (context = "outdoor", values = (high: Boolean) => if high then 12 else 3)
val outdoor = readings.from(supplied)
val recovered = readings.to(outdoor)
require(recovered.context == supplied.context)
require(recovered.values(false) == supplied.values(false))
require(recovered.values(true) == supplied.values(true))

val roundTrip = readings.from(readings.to(sensor))
require(roundTrip.label == sensor.label)
require(roundTrip.readings(false) == sensor.readings(false))
require(roundTrip.readings(true) == sensor.readings(true))
(outdoor.label, outdoor.readings(false), outdoor.readings(true))
// res23: Tuple3[String, Int, Int] = ("outdoor", 3, 12)
(recovered.context, recovered.values(false), recovered.values(true))
// res24: Tuple3[String, Int, Int] = ("outdoor", 3, 12)

Unlike the Grate above, this Glass needs a String context to reconstruct. It need not receive an old source, but a values-only tabulation is not enough. User-written Indexed.apply constructors must satisfy both round trips: from(to(source)) recovers the source, and to(from(bundle)) recovers arbitrary context and values pointwise. Functions are compared at their coordinates here, not with Scala function equality. Polymorphic constructors also need the corresponding target-side decomposition to state the latter law.

Rebuild a grid without losing row metadata

Nest Labelled to model a labelled installation with independently labelled sensor rows. Composition must retain each row's label, not just the installation's label or one representative row.

val installationRows = labelled[Labelled[Int], Labelled[Int]]
val installationGrid = installationRows.andThen(readings)

val installation = Labelled[Labelled[Int]](
  "plant",
  row => if row then
    Labelled[Int]("return", high => if high then 40 else 30)
  else
    Labelled[Int]("supply", high => if high then 20 else 10)
)

val original = installationGrid.to(installation)
val rebuilt = installationGrid.from((
  context = original.context,
  values = (coordinate: (Boolean, Boolean)) =>
    (if coordinate._1 then 300 else 100) + (if coordinate._2 then 2 else 1)
))

require(rebuilt.label == "plant")
require(rebuilt.readings(false).label == "supply")
require(rebuilt.readings(true).label == "return")
require(installationGrid.at((false, true))(rebuilt) == 102)
require(installationGrid.at((true, false))(rebuilt) == 301)

val blankInstallation = installationGrid.replace(0)(installation)
require(blankInstallation.label == installation.label)
require(blankInstallation.readings(false).label == installation.readings(false).label)
require(blankInstallation.readings(true).label == installation.readings(true).label)
(original.context._1, original.context._2(false), original.context._2(true))
// res33: Tuple3[X, String, String] = ("plant", "supply", "return")
(rebuilt.label, rebuilt.readings(false).label, rebuilt.readings(true).label)
// res34: Tuple3[String, String, String] = ("plant", "supply", "return")
(
  installationGrid.at((false, false))(rebuilt),
  installationGrid.at((false, true))(rebuilt),
  installationGrid.at((true, false))(rebuilt),
  installationGrid.at((true, true))(rebuilt)
)
// res35: Tuple4[Int, Int, Int, Int] = (101, 102, 301, 302)
(
  blankInstallation.label,
  blankInstallation.readings(false).label,
  blankInstallation.readings(true).label,
  installationGrid.at((false, true))(blankInstallation),
  installationGrid.at((true, false))(blankInstallation)
)
// res36: Tuple5[String, String, String, Int, Int] = (
//   "plant",
//   "supply",
//   "return",
//   0,
//   0
// )

Here X is (String, Boolean => String): the installation label and a tabulation of row labels. from pairs each new row of values with its own residual label before reconstructing the outer structure. Indexed modify and replace follow that same reconstruction rule.

Choosing and composing the family

For variable-shape containers and aggregations, see MultiFocus. For the surrounding optic families, see the Optics reference; for task-oriented examples, see the Cookbook.