Optics

Profunctor optics: first-class field accessors and pattern matchers.

Optics let you focus on parts of a data structure -- reading, writing, and transforming nested fields or enum variants -- without breaking encapsulation. Karpal provides two concrete optic types: Lens for product types (structs) and Prism for sum types (enums). Both connect to the profunctor hierarchy, enabling generic transformations through Strong and Choice respectively.

All optic types live in the karpal-optics crate and implement the Optic marker trait.

Optic

Marker trait for the optic family.

Signature

/// Marker trait for all optics.
///
/// This trait exists to unify the optic family under a single taxonomy.
/// Concrete optic types (Lens, Prism, etc.) implement this trait.
pub trait Optic {}

Optic carries no methods. It exists solely to classify types as optics, which is useful for trait bounds and documentation. Lens, ComposedLens, and Prism all implement Optic.

Lens

A first-class getter/setter pair for focusing on a field inside a product type.

Struct definition

/// A van Laarhoven-style lens encoded with getter/setter function pointers.
///
/// `S` -- source type, `T` -- modified source type,
/// `A` -- focus type, `B` -- replacement type.
pub struct Lens<S, T, A, B> {
    getter: fn(&S) -> A,
    setter: fn(S, B) -> T,
}

/// A simple (monomorphic) lens where `S == T` and `A == B`.
pub type SimpleLens<S, A> = Lens<S, S, A, A>;

The four type parameters support polymorphic update: you can replace a field of type A with a value of type B, changing the source from S to T. In practice, most lenses are simple (monomorphic), where S == T and A == B. The SimpleLens type alias covers this common case.

Methods

impl<S, T, A, B> Lens<S, T, A, B> {
    /// Create a new lens from a getter and setter.
    pub fn new(getter: fn(&S) -> A, setter: fn(S, B) -> T) -> Self;

    /// Extract the focus from the source.
    pub fn get(&self, s: &S) -> A;

    /// Replace the focus, producing a new source.
    pub fn set(&self, s: S, b: B) -> T;

    /// Chain another lens to focus deeper, producing a ComposedLens.
    /// Requires all type parameters to be `'static`.
    pub fn then<X, Y>(self, inner: Lens<A, B, X, Y>) -> ComposedLens<S, T, X, Y>
    where
        S: 'static, T: 'static, A: 'static, B: 'static,
        X: 'static, Y: 'static;
}

impl<S: Clone, T, A, B> Lens<S, T, A, B> {
    /// Modify the focus by applying a function. Requires `S: Clone`.
    pub fn over(&self, s: S, f: impl FnOnce(A) -> B) -> T;

    /// Profunctor encoding: transform a `P<A, B>` into a `P<S, T>`.
    /// Requires `S: Clone` and `Strong` profunctor `P`.
    /// All type parameters must be `'static`.
    pub fn transform<P: Strong>(&self, pab: P::P<A, B>) -> P::P<S, T>
    where
        S: 'static, T: 'static, A: 'static, B: 'static;
}

How transform works (Strong)

The transform method connects a concrete lens to the profunctor hierarchy through the Strong trait. Given any Strong profunctor P and a value pab: P<A, B>, it produces P<S, T> by:

  1. P::first(pab) lifts to P<(A, S), (B, S)>
  2. P::dimap pre-composes with |s| (get(s), s) and post-composes with |(b, s)| set(s, b)
pub fn transform<P: Strong>(&self, pab: P::P<A, B>) -> P::P<S, T>
where
    S: 'static, T: 'static, A: 'static, B: 'static,
{
    let getter = self.getter;
    let setter = self.setter;
    let first_pab = P::first::<A, B, S>(pab);
    P::dimap(
        move |s: S| {
            let a = getter(&s);
            (a, s)
        },
        move |(b, s)| setter(s, b),
        first_pab,
    )
}

Laws

A well-behaved lens must satisfy three laws:

GetSet

Setting a value you just got changes nothing:

lens.set(s.clone(), lens.get(&s)) == s
SetGet

Getting after setting yields the value you set:

lens.get(&lens.set(s, b)) == b
SetSet

Setting twice is the same as setting once with the second value:

lens.set(lens.set(s.clone(), b1), b2) == lens.set(s, b2)

Example

use karpal_optics::{Lens, SimpleLens};

#[derive(Debug, Clone, PartialEq)]
struct Person {
    name: String,
    age: u32,
}

let age_lens: SimpleLens<Person, u32> = Lens::new(
    |p: &Person| p.age,
    |p, age| Person { age, ..p },
);

let alice = Person { name: "Alice".into(), age: 30 };

// get
assert_eq!(age_lens.get(&alice), 30);

// set
let updated = age_lens.set(alice.clone(), 31);
assert_eq!(updated.age, 31);

// over -- modify the focus with a function
let updated = age_lens.over(alice.clone(), |a| a + 1);
assert_eq!(updated.age, 31);

Profunctor usage with FnP

use karpal_optics::{Lens, SimpleLens};
use karpal_profunctor::FnP;

let age_lens: SimpleLens<Person, u32> = Lens::new(
    |p: &Person| p.age,
    |p, age| Person { age, ..p },
);

let increment: Box<dyn Fn(u32) -> u32> = Box::new(|age| age + 1);
let transform_fn = age_lens.transform::<FnP>(increment);

let result = transform_fn(Person { name: "Alice".into(), age: 30 });
assert_eq!(result.age, 31);

ComposedLens

A lens built by chaining two or more lenses for deep field access.

Struct definition

/// A composed lens built from two lenses chained together.
///
/// Unlike `Lens`, which stores `fn` pointers, a composed lens stores
/// boxed closures because closure composition cannot produce `fn` pointers.
pub struct ComposedLens<S, T, X, Y> {
    getter: Box<dyn Fn(&S) -> X>,
    setter: Box<dyn Fn(S, Y) -> T>,
}

/// A simple (monomorphic) composed lens where `S == T` and `X == Y`.
pub type SimpleComposedLens<S, X> = ComposedLens<S, S, X, X>;

ComposedLens is produced by calling Lens::then() or ComposedLens::then(). It stores Box<dyn Fn> closures instead of fn pointers because closure composition captures the outer lens's getter and setter, which cannot be represented as bare function pointers.

Methods

impl<S, T, X, Y> ComposedLens<S, T, X, Y> {
    /// Extract the deeply-nested focus from the source.
    pub fn get(&self, s: &S) -> X;

    /// Replace the deeply-nested focus, producing a new source.
    pub fn set(&self, s: S, y: Y) -> T;
}

impl<S: Clone, T, X, Y> ComposedLens<S, T, X, Y> {
    /// Modify the deeply-nested focus by applying a function. Requires `S: Clone`.
    pub fn over(&self, s: S, f: impl FnOnce(X) -> Y) -> T;

    /// Chain another lens to focus even deeper.
    /// All type parameters must be `'static`.
    pub fn then<U, V>(self, inner: Lens<X, Y, U, V>) -> ComposedLens<S, T, U, V>
    where
        S: 'static, T: 'static, X: 'static, Y: 'static,
        U: 'static, V: 'static;
}

No transform on ComposedLens

ComposedLens does not provide a transform method. For profunctor-level composition, use nested Lens::transform calls on the original lenses instead:

// Instead of composed_lens.transform::<P>(pab), write:
let result = outer.transform::<P>(inner.transform::<P>(pab));

This avoids the need for Rc/Arc to share closures at the profunctor level and preserves the clean semantics of the profunctor encoding.

Example

use karpal_optics::{Lens, SimpleLens};

#[derive(Debug, Clone, PartialEq)]
struct Company {
    name: String,
    ceo: Person,
}

let ceo_lens: SimpleLens<Company, Person> = Lens::new(
    |c: &Company| c.ceo.clone(),
    |c, ceo| Company { ceo, ..c },
);

let age_lens: SimpleLens<Person, u32> = Lens::new(
    |p: &Person| p.age,
    |p, age| Person { age, ..p },
);

// Compose: Company -> ceo -> age
let ceo_age = ceo_lens.then(age_lens);

let acme = Company {
    name: "Acme".into(),
    ceo: Person { name: "Alice".into(), age: 30 },
};

assert_eq!(ceo_age.get(&acme), 30);

let updated = ceo_age.set(acme.clone(), 31);
assert_eq!(updated.ceo.age, 31);

let updated = ceo_age.over(acme, |age| age + 1);
assert_eq!(updated.ceo.age, 31);

Prism

A first-class pattern matcher for focusing on one variant of a sum type.

Struct definition

/// A prism focuses on one variant of a sum type.
///
/// `S` -- source type, `T` -- modified source type,
/// `A` -- focus type (the variant's inner value), `B` -- replacement type.
///
/// Where a Lens uses Strong to decompose products, a Prism uses Choice
/// to decompose coproducts.
pub struct Prism<S, T, A, B> {
    /// Attempt to match. `Ok(a)` = matched, `Err(t)` = didn't match (pass-through).
    match_: fn(S) -> Result<A, T>,
    /// Construct a `T` from the replacement value.
    build: fn(B) -> T,
}

/// A simple (monomorphic) prism where `S == T` and `A == B`.
pub type SimplePrism<S, A> = Prism<S, S, A, A>;

A Prism is the dual of a Lens. Where a lens focuses on a field that is always present (product types), a prism focuses on a variant that may or may not be present (sum types). The match_ function returns Ok(a) if the variant matches and Err(t) if it does not, allowing the original value to pass through unchanged.

Methods

impl<S, T, A, B> Prism<S, T, A, B> {
    /// Create a new prism from a match function and a build function.
    pub fn new(match_: fn(S) -> Result<A, T>, build: fn(B) -> T) -> Self;

    /// Try to extract the focus. Returns `Some(a)` if the variant matches.
    /// Requires `S: Clone`.
    pub fn preview(&self, s: &S) -> Option<A>
    where
        S: Clone;

    /// Construct a `T` from a replacement value (inject/construct).
    pub fn review(&self, b: B) -> T;

    /// Replace the focus if the variant matches; otherwise pass through.
    pub fn set(&self, s: S, b: B) -> T;

    /// Modify the focus if the variant matches; otherwise pass through.
    pub fn over(&self, s: S, f: impl FnOnce(A) -> B) -> T;

    /// Profunctor encoding: transform a `P<A, B>` into a `P<S, T>`.
    /// Requires `Choice` profunctor `P`.
    /// All type parameters must be `'static`.
    pub fn transform<P: Choice>(&self, pab: P::P<A, B>) -> P::P<S, T>
    where
        S: 'static, T: 'static, A: 'static, B: 'static;
}

How transform works (Choice)

The transform method connects a concrete prism to the profunctor hierarchy through the Choice trait. Given any Choice profunctor P and a value pab: P<A, B>, it produces P<S, T> by:

  1. P::right(pab) lifts to P<Result<T, A>, Result<T, B>>
  2. P::dimap pre-composes with match_ (swapping Ok/Err arms) and post-composes with build (reassembling)

The arm-swapping (Ok to Err, Err to Ok in pre-composition) is necessary because Choice::right acts on the Err branch of Result.

pub fn transform<P: Choice>(&self, pab: P::P<A, B>) -> P::P<S, T>
where
    S: 'static, T: 'static, A: 'static, B: 'static,
{
    let match_ = self.match_;
    let build = self.build;
    let right_pab = P::right::<A, B, T>(pab);
    P::dimap(
        move |s: S| match match_(s) {
            Ok(a) => Err(a),  // focus found -- Err arm for Choice::right
            Err(t) => Ok(t),  // no match -- Ok arm passes through
        },
        move |result: Result<T, B>| match result {
            Ok(t) => t,          // passed through unchanged
            Err(b) => build(b),  // transformed, rebuild
        },
        right_pab,
    )
}

Laws

A well-behaved prism must satisfy two laws:

PreviewReview

If a preview succeeds, reviewing the result reconstructs the original:

if let Some(a) = prism.preview(&s) {
    assert_eq!(prism.review(a), s);
}
ReviewPreview

Previewing a value built with review always succeeds and returns the original value:

assert_eq!(prism.preview(&prism.review(b)), Some(b));

Example

use karpal_optics::{Prism, SimplePrism};

#[derive(Debug, Clone, PartialEq)]
enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
}

let circle: SimplePrism<Shape, f64> = Prism::new(
    |s| match s {
        Shape::Circle(r) => Ok(r),
        Shape::Rectangle(w, h) => Err(Shape::Rectangle(w, h)),
    },
    Shape::Circle,
);

// preview -- extract if the variant matches
assert_eq!(circle.preview(&Shape::Circle(5.0)), Some(5.0));
assert_eq!(circle.preview(&Shape::Rectangle(3.0, 4.0)), None);

// review -- construct the variant
assert_eq!(circle.review(10.0), Shape::Circle(10.0));

// set -- replace the focus if matched
assert_eq!(circle.set(Shape::Circle(5.0), 10.0), Shape::Circle(10.0));
assert_eq!(
    circle.set(Shape::Rectangle(3.0, 4.0), 10.0),
    Shape::Rectangle(3.0, 4.0),
);

// over -- modify the focus if matched
assert_eq!(
    circle.over(Shape::Circle(5.0), |r| r * 2.0),
    Shape::Circle(10.0),
);

Profunctor usage with FnP

use karpal_optics::{Prism, SimplePrism};
use karpal_profunctor::FnP;

let circle: SimplePrism<Shape, f64> = Prism::new(
    |s| match s {
        Shape::Circle(r) => Ok(r),
        Shape::Rectangle(w, h) => Err(Shape::Rectangle(w, h)),
    },
    Shape::Circle,
);

let double: Box<dyn Fn(f64) -> f64> = Box::new(|r| r * 2.0);
let transform_fn = circle.transform::<FnP>(double);

// Matching variant is transformed
assert_eq!(transform_fn(Shape::Circle(5.0)), Shape::Circle(10.0));

// Non-matching variant passes through unchanged
assert_eq!(
    transform_fn(Shape::Rectangle(3.0, 4.0)),
    Shape::Rectangle(3.0, 4.0),
);

Composing Lenses with .then()

Lenses compose naturally via the .then() method. Each call to then produces a ComposedLens that focuses one level deeper. You can chain as many lenses as needed for deep access into nested structures.

use karpal_optics::{Lens, SimpleLens};

#[derive(Debug, Clone, PartialEq)]
struct Address {
    street: String,
    city: String,
}

#[derive(Debug, Clone, PartialEq)]
struct Employee {
    name: String,
    addr: Address,
}

#[derive(Debug, Clone, PartialEq)]
struct Org {
    title: String,
    lead: Employee,
}

let lead_lens: SimpleLens<Org, Employee> = Lens::new(
    |o: &Org| o.lead.clone(),
    |o, lead| Org { lead, ..o },
);

let addr_lens: SimpleLens<Employee, Address> = Lens::new(
    |e: &Employee| e.addr.clone(),
    |e, addr| Employee { addr, ..e },
);

let city_lens: SimpleLens<Address, String> = Lens::new(
    |a: &Address| a.city.clone(),
    |a, city| Address { city, ..a },
);

// Three-deep composition: Org -> lead -> addr -> city
let org_city = lead_lens.then(addr_lens).then(city_lens);

let org = Org {
    title: "R&D".into(),
    lead: Employee {
        name: "Alice".into(),
        addr: Address {
            street: "123 Main St".into(),
            city: "Springfield".into(),
        },
    },
};

// Read a deeply nested field
assert_eq!(org_city.get(&org), "Springfield");

// Update a deeply nested field
let updated = org_city.set(org.clone(), "Shelbyville".into());
assert_eq!(updated.lead.addr.city, "Shelbyville");
assert_eq!(updated.lead.addr.street, "123 Main St");

// Modify a deeply nested field with a function
let updated = org_city.over(org, |c| c.to_uppercase());
assert_eq!(updated.lead.addr.city, "SPRINGFIELD");

For profunctor-level composition (where you need transform), nest the original lens transforms instead of using the composed lens:

use karpal_profunctor::FnP;

let ceo_lens: SimpleLens<Company, Person> = /* ... */;
let age_lens: SimpleLens<Person, u32> = /* ... */;

let increment: Box<dyn Fn(u32) -> u32> = Box::new(|age| age + 1);

// Nested transform: equivalent to composed_lens.over(company, |age| age + 1)
let transform_fn = ceo_lens.transform::<FnP>(age_lens.transform::<FnP>(increment));