📢 [ANN] optics 0.1.0 — a no-bullshit, no_std, dependency-free optics library for Rust

91 points Due-Monitor-1084 37 comments

Hey folks — I just pushed out the first pre-release of a crate called optics. It's a lightweight, layman implementation of functional optics for Rust — with no dependencies, no_std support, and a focus on doing one thing cleanly and aiming not to require a PhD it in type theory to understand.

🧐 What’s This About?

optics is a set of composable, type-safe tools for accessing, transforming, and navigating data structures. It takes inspiration from the optics concepts you'd find in functional languages like Haskell — but it’s designed by someone who does not have a complete grasp on type theory or Van Laarhoven/profunctor lenses.

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.

✨ Features

  • Lenses — for focusing on subfields of structs
  • Prisms — for working with enum variants
  • Isomorphisms — for invertible type transformations
  • Fallible Isomorphisms — for conversions that might fail (e.g., String ↔ u16)
  • Composable — optics can be chained together to drill down into nested structures
  • No dependencies — pure Rust, no external crates
  • no_std support — usable in embedded and other restricted environments
  • Type-safe, explicit interfaces
  • Honest documentation

📦 Philosophy

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.

Some of the generic type bounds are clunky. I ran into situations where missing negative trait bounds in Rust forced some awkward decisions. There’s also a lot of repetition in the code — some of it could likely be reduced with macros, but I’m cautious about that since excessive macro usage tends to kill readability and maintainability.

I genuinely welcome critics, feedback, and suggestions. If you see a way to clean up the generics, improve trait compositions, or simplify the code structure, I’m all ears. Drop me a PR, an issue, or a comment.

📖 Simple Example

Let’s say you have a config struct for a hypothetical HTTP server:

use optics::{LensImpl, FallibleIsoImpl, PrismImpl, Optic, NoFocus};
use optics::composers::{ComposableLens, ComposablePrism};

#[derive(Debug, Clone)]
struct HttpConfig {
  bind_address: Option<String>,
  workers: usize,
}

#[derive(Debug, Clone)]
struct AppConfig {
  http: HttpConfig,
  name: String,
}

struct MyError;

impl From<MyError> for NoFocus {
  fn from(_: MyError) -> Self {
    NoFocus
  }
}

impl From<NoFocus> for MyError {
  fn from(_: NoFocus) -> Self {
    unreachable!()
  }
}


fn main() {
  // Define lenses to focus on subfields
  let http_lens = LensImpl::<AppConfig, HttpConfig>::new(
    |app| app.http.clone(),
    |app, http| app.http = http,
  );

  let bind_address_prism = PrismImpl::<HttpConfig, String>::new(
    |http| http.bind_address.clone(),
    |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 = FallibleIsoImpl::<String, u16, MyError, _, _>::new(
    |addr: &String| {
      addr.rsplit(':')
        .next()
        .and_then(|port| port.parse::<u16>().ok()).ok_or(MyError)
    },
    move |port: &u16| if *port > minimum_port { Ok(format!("0.0.0.0:{}", port)) } else { Err(MyError) }
  );

  // Compose lens and fallible iso into a ComposedFallibleIso

  let http_bind_address_prism = http_lens.compose_lens_with_prism(bind_address_prism);
  let http_bind_address_port_prism = http_bind_address_prism.compose_prism_with_fallible_iso::<MyError>(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);

  // Use it to increment the port and update the config
  http_bind_address_port_prism.set(&mut config, port + 1);

  println!("Updated config: {:?}", config);
}

Benefits

🔴 Without optics:

Say you have a big config struct:


pub struct Config {
  pub network: NetworkConfig,
  pub database: DatabaseConfig,
}

pub struct NetworkConfig {
  pub port: u16,
}

pub struct DatabaseConfig {
  pub path: String,
}   `

If you want a submodule to update the database path:

```rust
set_db_path(cfg: &mut Config, new_path: String) {
    cfg.database.path = new_path;
}

Why is this problematic?

  • Config and its fields need to be pub or at least pub(crate) to be accessed.

  • Submodules either need to know the entire Config layout or you have to write proxy methods.

  • You can't easily decouple who can see what — it’s baked into the type’s visibility modifiers.

  • Hard to selectively expose or overlap parts of the config dynamically or across crate boundaries.

🟢 With optics (lenses):

Now let’s make Config opaque:

pub struct Config {
  network: NetworkConfig,
  database: DatabaseConfig,
}

struct NetworkConfig {
  port: u16,
}

struct DatabaseConfig {
  path: String,
}

Notice: Nothing is pub anymore. Nobody outside this module can touch any of it.

But — we can expose an optics lens to safely access just what’s needed, then, the submodule can be passed just this:

fn set_db_path<L>(cfg: &mut Config, lens: &L, new_path: String) where L: Lens<Config, String> {
  lens.set(cfg, new_path);
}

Now, why is this better?

  • Submodules have zero visibility into Config.

  • You decide what part of the config they can access at init time by giving them a lens.

  • You can dynamically compose or overlap lenses — something that’s impossible with static Rust visibility rules.

  • No need for pub or proxy methods or wrapping everything in Arc> just to pass around bits of config.

  • Cleaner separation of concerns: the submodule knows how to use a value, but not where it comes from.

  • Can also be used to transform values no matter where they are in a struct, akin to mutable references, but more flexible if parsing is involved via an Iso

**In my real use case:**I have a system where one giant config struct holds multiple submodules’ configs. During init:

  • Each submodule gets an optic pointing to the config parts it should see.

  • Some optics overlap intentionally (for shared settings).

  • Submodules can only access what they’re passed.

  • No cross-module config leakage, no awkward visibility workarounds, even across crate boundaries.

📦 Install

[dependencies]
optics = "0.1.0"

📖 Docs

Full documentation: https://docs.rs/optics

📌 Status

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. Type-level constraints, trait bounds, and generic compositions are kind of bloated right now, and I wouldn’t mind help tightening it up.

💬 Call for Critics

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.

Thanks for reading!

Would genuinely appreciate your feedback or PRs if you think this little library has potential.

Disclaimer: This post (and some of the code) was generated using ChatGPT and obviously reviewed, but sorry for any redundancies.