optics
is a set of composable, type-safe tools for accessing, transforming, and navigating data structures. It allows to separate navigation and performing operations on the data structures. Using optics a function is even able to perform a operations on otherwise opaque data structures.
It takes inspiration from the optics concepts you'd find in functional languages like Haskell β but itβs not an implementatiton following strict type theory or Van Laarhoven/profunctor lenses, but it tries to mimic similar functionality within the constraints of Rustβs type system without higher-kinded types.
The goal was simple:
π Build something useful and composable for everyday Rust projects β no magic.
This is a pre-release, and the code is unfinished β but itβs good enough to start experimenting with in real projects.
Thereβs a lot of room for simplification and improvement and I wouldnβt mind help and ideas to do so.
- No dependencies β pure Rust, no external crates except for testing
no_std
support β usable in embedded and other restricted environments- Type-safe, explicit interfaces
This is a layman's implementation of optics. I donβt fully grasp all the deep type theory behind profunctor optics or Van Laarhoven lenses. Instead, I built something practical and composable, within the limitations of Rustβs type system and my own understanding.
All optic implementations implement a set of base traits that define the operations they can perform. Currently three base operations are defined: (HasSetter
, HasGetter
, HasReverseGet
). This will likely need to be extended in the future to make it possible to add a Traversal
for example.
When certain conditions are met, specific marker traits are implemented for other base operations, such as HasTotalGetter
(if the HasGetter
error is Infallible
), HasOver
(if the optic is at least a prism), or HasTotalReverseGet
(if the HasReverseGet
error is Infallible
.
Concrete structs of implementations of the optics are private, and interaction with optics is only allowed when wrapped in an exposed Impl
struct (constructor functions returning Impl
are exposed). This can be used to combine optics or to downgrade an optic, such as a Lens
into a Getter
, if the desired behaviour is to restrict the optic to only allow reading data.
Optics - even if they are of different types can be combined. The rule of thumb is that the combination of two optics X<S, I> and Y<I, A> will result in the most advanced optic type that requires a set of base traits that both components implement:
PartialGetter |
Getter | Prism | Lens | Iso | FallibleIso |
Setter | |
---|---|---|---|---|---|---|---|
PartialGetter |
PartialGetter |
PartialGetter |
PartialGetter |
PartialGetter |
PartialGetter |
PartialGetter |
- |
Getter | PartialGetter |
Getter | PartialGetter |
Getter | Getter | PartialGetter |
- |
Prism | PartialGetter |
PartialGetter |
Prism | Prism | Prism | Prism | - |
Lens | PartialGetter |
Getter | Prism | Lens | Lens | Prism | - |
Iso | PartialGetter |
Getter | Prism | Lens | Iso | FallibleIso |
- |
FallibleIso |
PartialGetter |
PartialGetter |
Prism | Prism | FallibleIso |
FallibleIso |
- |
Setter | - | - | Setter | Setter | Setter | Setter | - |
- [
PartialGetter
] - for fallible read-only access to data - [
Getter
] - for read-only access to data - [
Setter
] - for write-only access to data - [
Prism
] β Prisms in general allow for focusing on a specific variant of a sum type (like enums in Rust). They can be used to extract or modify the value of that variant, or a focusing operation that may fail because the value that may or may not be present, such asOption<u32>
->u32
- [
Lens
] β Lenses focus on a part of a structure and provide a way to get and set the value of that part, such asPoint
->x: u32
- [
Iso
]morphisms β Isos provide a bijective mapping between types. They can be used to transform data between two types while preserving structure, such as anIpAddrV4
<=>u32
, or aCartesianPoint
andPolarPoint
- [
FallibleIso
]morphisms β Fallible isos extend the concept of isos by introducing the possibility of failure. Both the getting and setting operations may fail, and they return Result types that allow you to handle errors. This can be used for parsing or validating data, such as converting aString
to anu16
.
The crate was designed in a way that allows for easy extensibility. Both in terms of adding new optic types (w/ base traits), or adding new implementations of existing optics, such as a lens that can handle Options of any type.
This section outlines the structural conventions and design patterns employed in the optics crate. Adhering to these guidelines ensures consistency, maintainability, and extensibility across the crate's codebase, and is to some extent enforced by tests.
Each optic type (e.g., Lens, Prism, Iso) is encapsulated within its own module. These modules are not directly exposed to downstream users. The organization within each module is as follows:
A marker trait is defined to represent the optic type. This trait defines as supertraits the necessary base optic traits required for its functionality. For example, a Prism marker trait would extend HasPartialGetter
and HasSetter
. The marker trait has a blanket implementation to be automatically implement for all structs that implement the required base traits.
An Impl struct (e.g., LensImpl
) serves as the public interface for the optic type. The semantics of the Impl wrapper is "a container of optics that currently acts as a ..." This struct wraps the concrete implementation opaquely and is directly returned by the crate's API. Utilizing a concrete struct also allows to use correct combining function signatures and allow downgrading an optic to an inferior type (lens to prism, iso to getter).
The Impl struct is responsible for:
- Implementing all base optic traits its wrapped optic implements, and allowing for casting between different optic types (e.g., from Iso to Lens or Prism).
- Providing
combine_with_xxx
functions to compose optics, returning an Impl of the resulting optic type. - Providing
as_xxx
functions returning an Impl of an inferior optic type (e.g., from Lens to Prism or Getter).
A composed.rs
file within each module contains implementations that compose two optics to form the current optic type. For instance, a ComposedPrism
might combine a Lens
and a FallibleIso
. In some cases errors need to be wrapped either automatically if they implement Into<>
, or by mapping functions.
The module is entirely private to the crate, only a constructor function new
is exposed.
Though not strictly required, currently all optics provide an implementation using closures.
The modules are entirely private to the crate, only a constructor function new
is exposed.
Other files within the module may provide alternative implementations of the optic type, such as concrete structs for specific higher-kinded types (HKTs) that cannot be expressed with closures alone, such as Some, Result<T, Err>, and Vec. Due to Rust's lack of native support for HKTs, it's not possible to implement a general Functor<_> trait as in Haskell. Consequently, each mapped implementation can only be tailored to a specific type constructor only.
These modules are also intended to be private, only exposing a constructor function returning an Impl
struct. If you add new implementations either to the crate or to your own crate, please follow this guideline to avoid gotchas.
When introducing a new optic type or implementation:
- Create a New Module: Define a new module for the optic type, following the naming convention (e.g., lens, prism).
- Define the Marker Trait: Inside the module, define a marker trait that extends the appropriate base optic traits. Add a blanket implementation for all structs that implement the required base traits.
- Implement the Optic: Provide a concrete struct that implements the base optic traits and the marker trait. Add it as a submodule under the optic type module.
- Wrap with Impl Struct: Create an Impl struct in a wrapper module that wraps the concrete implementation and implements downgrading and combining functions.
- Compose Optics: If the optic type can be composed from existing optics, implement the composition in the composed.rs file. Add extension traits for Impls of other optic types, and implement combining and downgrading functions to the optic being implemented if it makes sense.
- Additional Implementations: Consider alternative implementations using closures or concrete structs for specific HKTs, as needed.
- Pull requests are welcome :)
By following these conventions, the optics crate maintains a consistent and extensible framework for optic types, promoting code reuse and reducing the potential for conflicts in trait implementations.
The table below shows which optics can act as another optic type.
Getter |
TotalGetter |
Prism |
Lens |
Iso |
FallibleIso |
Setter |
|
---|---|---|---|---|---|---|---|
Getter |
β | ||||||
TotalGetter |
β | β | |||||
Prism |
β | β | β | ||||
Lens |
β | β | β | β | β | ||
Iso |
β | β | β | β | β | β | β |
FallibleIso |
β | β | β | β | |||
Setter |
β |
If you know your type theory, or even if you just have an eye for clean Rust APIs β Iβd love for you to take a look. Suggestions, critiques, and even teardown reviews are welcome. This is very much a learning-while-doing project for me.
Below is a simplified example of how the optics work in this crate. The code below illustrates how to use and combine the various optic types.
use optics::{LensImpl, FallibleIsoImpl, PrismImpl, mapped_lens, mapped_prism, mapped_fallible_iso, HasSetter, HasGetter};
#[derive(Debug, Clone)]
struct HttpConfig {
bind_address: Option<String>,
workers: usize,
}
#[derive(Debug, Clone)]
struct AppConfig {
http: HttpConfig,
name: String,
}
fn example() {
// Define lenses to focus on subfields
let http_lens = mapped_lens(
|app: &AppConfig| app.http.clone(),
|app, http| app.http = http,
);
let bind_address_prism = mapped_prism(
|http: &HttpConfig| http.bind_address.clone().ok_or(()),
|http, addr| http.bind_address = Some(addr),
);
let minimum_port = 1024;
// Define a fallible isomorphism between String and u16 (parsing a port)
let port_fallible_iso = mapped_fallible_iso(
|addr: &String| {
addr.rsplit(':')
.next()
.and_then(|port| port.parse::<u16>().ok()).ok_or(())
},
move |port: &u16| if *port > minimum_port { Ok(format!("0.0.0.0:{}", port)) } else { Err(()) }
);
// Compose lens and fallible iso into a ComposedFallibleIso
let http_bind_address_prism = http_lens.compose_with_prism(bind_address_prism);
let http_bind_address_port_prism = http_bind_address_prism.compose_with_fallible_iso::<(), _, _>(port_fallible_iso);
let mut config = AppConfig {
http: HttpConfig {
bind_address: Some("127.0.0.1:8080".to_string()),
workers: 4,
},
name: "my_app".into(),
};
// Use the composed optic to get the port
let port = http_bind_address_port_prism.try_get(&config).unwrap();
println!("Current port: {}", port); // 8080
// Use it to increment the port and update the config
http_bind_address_port_prism.set(&mut config, port + 1);
println!("Updated bind address: {:?}", config.http.bind_address); // port is now 8081
}
example();
While the code was written with care, parts of the documentation and some of the tests are AI generated, especially the code quality tests.