ocular/compose
Composition functions for combining optics.
This module provides functions for composing optics together. The naming convention
follows F# Aether: same-type compositions use the optic name (e.g., lens), while
cross-type compositions combine the names (e.g., lens_opt, prism_lens).
Same-Type Composition
import ocular/compose as c
// Compose two lenses
let street_lens = user_address_lens |> c.lens(address_street_lens)
// Compose two optionals
let deep_opt = level1_opt |> c.optional(level2_opt)
// Compose two prisms
let circle_or_rect = outer_prism |> c.prism(inner_prism)
Cross-Type Composition
// Lens + Optional = Optional
let city_opt = user_address_lens |> c.lens_opt(address_city_opt)
// Prism + Lens = Optional (review can't be implemented)
let some_name_opt = some_prism |> c.prism_lens(person_name_lens)
// Iso + Lens = Lens
let wrapped_name = wrapper_iso |> c.iso_lens(person_name_lens)
Composition Table
| Outer | Inner | Result | Function |
|---|---|---|---|
| Lens | Lens | Lens | lens |
| Lens | Optional | Optional | lens_opt |
| Optional | Lens | Optional | opt_lens |
| Optional | Optional | Optional | optional |
| Prism | Prism | Prism | prism |
| Prism | Lens | Optional | prism_lens |
| Prism | Optional | Optional | prism_opt |
| Iso | Iso | Iso | iso |
| Iso | Lens | Lens | iso_lens |
| Iso | Prism | Prism | iso_prism |
| Iso | Optional | Optional | iso_opt |
| Optional | Prism | Optional | opt_prism |
Values
pub fn iso(
outer: types.Iso(a, b, c, d),
inner: types.Iso(c, d, e, f),
) -> types.Iso(a, b, e, f)
Compose two isos.
Example
// Iso: List(a) <-> List(a) (reverse)
let reverse_iso = ocular.iso(
get: list.reverse,
reverse: list.reverse,
)
// Compose: double reverse = identity
let identity = reverse_iso |> compose.iso(reverse_iso)
pub fn iso_lens(
iso: types.Iso(a, b, c, d),
lens: types.Lens(c, d, e, f),
) -> types.Lens(a, b, e, f)
Compose an iso with a lens. Result: Lens
Example
// Wrapper iso: String <-> WrappedString
let wrapper_iso = ocular.iso(
get: fn(s: String) { WrappedString(s) },
reverse: fn(w: WrappedString) { w.value },
)
// Compose: WrappedString -> String -> Int (length)
let wrapped_length = wrapper_iso |> compose.iso_lens(string_length_lens)
pub fn iso_opt(
iso: types.Iso(a, a, c, c),
opt: types.Optional(c, c, e, e),
) -> types.Optional(a, a, e, e)
Compose an iso with an optional. Result: Optional
Example
// Convert Dict <-> List(Pair), then access by key
let dict_key_via_list = dict_list_iso |> compose.iso_opt(key_opt)
pub fn iso_prism(
iso: types.Iso(a, b, c, d),
prism: types.Prism(c, d, e, f),
) -> types.Prism(a, b, e, f)
Compose an iso with a prism. Result: Prism
Example
// Convert String <-> WrappedString, then match Some(WrappedString)
let wrapped_some = wrapper_iso |> compose.iso_prism(ocular.some())
pub fn lens(
outer: types.Lens(a, b, c, d),
inner: types.Lens(c, d, e, f),
) -> types.Lens(a, b, e, f)
Compose two lenses.
Example
pub type Address { Address(street: String) }
pub type User { User(address: Address) }
let address_lens = ocular.lens(
get: fn(u: User) { u.address },
set: fn(v, u: User) { User(..u, address: v) },
)
let street_lens = ocular.lens(
get: fn(a: Address) { a.street },
set: fn(v, a: Address) { Address(..a, street: v) },
)
// Compose: User -> Address -> String
let user_street = address_lens |> compose.lens(street_lens)
let user = User(address: Address(street: "Main St"))
let street = ocular.get(user, user_street) // "Main St"
pub fn lens_iso(
lens: types.Lens(a, b, c, d),
iso: types.Iso(c, d, e, f),
) -> types.Lens(a, b, e, f)
Compose a lens with an iso. Result: Lens
Example
// User.name is a String, convert to WrappedString
let wrapped_name = user_name_lens |> compose.lens_iso(wrapper_iso)
pub fn lens_opt(
lens: types.Lens(a, b, c, d),
opt: types.Optional(c, d, e, f),
) -> types.Optional(a, b, e, f)
Compose a lens with an optional. Result: Optional (since the path may not exist)
Example
pub type User { User(address: Option(Address)) }
pub type Address { Address(city: String) }
let address_opt = ocular.optional(
get: fn(u: User) {
case u.address {
Some(a) -> Ok(a)
None -> Error(Nil)
}
},
set: fn(v, u: User) { User(..u, address: Some(v)) },
)
let city_lens = ocular.lens(
get: fn(a: Address) { a.city },
set: fn(v, a: Address) { Address(..a, city: v) },
)
// Compose: User -?-> Address -> String (Optional result)
let city_opt = address_opt |> compose.lens_opt(city_lens)
let user = User(address: Some(Address(city: "NYC")))
let city = ocular.get_opt(user, city_opt) // Ok("NYC")
pub fn opt_lens(
opt: types.Optional(a, a, c, c),
lens: types.Lens(c, c, e, e),
) -> types.Optional(a, a, e, e)
Compose an optional with a lens. Result: Optional (since the outer path may not exist)
Example
// User has optional address, address has required street
let street_lens = ocular.lens(
get: fn(a: Address) { a.street },
set: fn(v, a: Address) { Address(..a, street: v) },
)
// Compose: User -?-> Address -> String
let street_opt = address_opt |> compose.opt_lens(street_lens)
pub fn opt_opt(
outer: types.Optional(a, a, c, c),
inner: types.Optional(c, c, e, e),
) -> types.Optional(a, a, e, e)
Compose two optionals (alias for optional).
pub fn opt_prism(
opt: types.Optional(a, a, c, c),
prism: types.Prism(c, c, e, e),
) -> types.Optional(a, a, e, e)
Compose an optional with a prism. Result: Optional
Example
// Optional address, address is Shape (Circle or Rectangle)
let address_circle = address_opt |> compose.opt_prism(circle_prism)
pub fn optional(
outer: types.Optional(a, a, c, c),
inner: types.Optional(c, c, e, e),
) -> types.Optional(a, a, e, e)
Compose two optionals. Result: Optional (both paths must exist)
Example
// Level1 -?-> Level2 -?-> String
let level1_opt: Optional(Level1, Level2) = ...
let level2_opt: Optional(Level2, String) = ...
let deep_opt = level1_opt |> compose.optional(level2_opt)
// Only succeeds if both levels exist
let result = ocular.get_opt(level1, deep_opt)
pub fn prism(
outer: types.Prism(a, a, c, c),
inner: types.Prism(c, c, e, e),
) -> types.Prism(a, a, e, e)
Compose two prisms. Both prisms must match for the composition to succeed.
Example
pub type Shape {
Circle(radius: Float)
Rectangle(width: Float, height: Float)
}
pub type Container {
Box(shape: Shape)
Bag(items: List(String))
}
let box_prism = ocular.prism(
get: fn(c: Container) {
case c {
Box(s) -> Ok(s)
Bag(_) -> Error(Nil)
}
},
set: fn(s, _c) { Box(s) },
review: fn(s) { Box(s) },
)
let circle_prism = ocular.prism(
get: fn(s: Shape) {
case s {
Circle(r) -> Ok(r)
Rectangle(_, _) -> Error(Nil)
}
},
set: fn(r, _s) { Circle(r) },
review: fn(r) { Circle(r) },
}
// Compose: Container -> Shape -> Float (only for Box(Circle(_)))
let box_circle_radius = box_prism |> compose.prism(circle_prism)
let container = Box(Circle(5.0))
let radius = ocular.preview(container, box_circle_radius) // Ok(5.0)
pub fn prism_iso(
prism: types.Prism(a, b, c, d),
iso: types.Iso(c, d, e, f),
) -> types.Prism(a, b, e, f)
Compose a prism with an iso. Result: Prism
Example
// Match Some(Int), then convert Int <-> String
let some_string = ocular.some() |> compose.prism_iso(int_string_iso)
pub fn prism_lens(
prism: types.Prism(a, a, c, c),
lens: types.Lens(c, c, e, e),
) -> types.Optional(a, a, e, e)
Compose a prism with a lens. Result: Optional (not Prism) because review cannot be implemented without a default value for the middle structure.
Example
// Option(Circle) with Circle having a radius field
let some_circle = ocular.some() |> compose.prism_lens(circle_radius_lens)
let maybe_circle = Some(Circle(5.0))
let radius = ocular.get_opt(maybe_circle, some_circle) // Ok(5.0)
pub fn prism_opt(
prism: types.Prism(a, a, c, c),
opt: types.Optional(c, c, e, e),
) -> types.Optional(a, a, e, e)
Compose a prism with an optional. Result: Optional
Example
// Shape is Circle or Rectangle, Circle has optional label
let circle_label = circle_prism |> compose.prism_opt(label_opt)