Every few months someone posts a new attempt at lenses in Rust to Lobsters or the Rust subreddit, and the discussion reliably covers the same ground: higher-kinded types, van Laarhoven encodings, profunctor optics, Generic Associated Types. The names pile up fast, and the thread usually ends with everyone agreeing that the status quo is unsatisfying but nobody quite agreeing on why. This recent post from lambdalemon.gay walks through the problem and presents a concrete solution, which is a good occasion to step back and look at the whole landscape.
What Lenses Are For
A lens is a composable, first-class handle on a part of a data structure. It bundles a getter and a setter together in a way that lets you snap multiple lenses end-to-end like pipeline stages. If you have a User with an Address with a ZipCode, you compose a lens for User -> Address with a lens for Address -> ZipCode and get a single lens for User -> ZipCode, without writing any glue code.
This sounds modest until you have deeply nested immutable data or you want to write generic code that operates on “some field of some type” without naming that type concretely. Functional code that manipulates config trees, game state, or document models benefits enormously from optics, because the alternative is either mutation in place (fine in Rust, but sometimes you specifically want a functional update) or hand-writing accessor pairs for every nesting level.
The Haskell lens library by Edward Kmett, introduced around 2012, made lenses mainstream. Its most important contribution was the van Laarhoven encoding, which expresses a lens as a single higher-order function rather than a struct with two fields:
type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t
Here s is the outer structure, a is the focused field, b is what you can replace it with, and t is the resulting outer structure after the replacement. The forall f. Functor f part is the key: by universally quantifying over all functors, a single function encodes both get and set. Pass Identity as the functor and you get a setter; pass Const and you get a getter. The payoff is that lenses compose using ordinary function composition, the same . you use everywhere else in Haskell. No special combinator, no traversal machinery, just (.).
The Rust Problem
Rust has no equivalent of forall f. Functor f. Higher-kinded types, the ability to abstract over type constructors like f in the above signature, are not part of the language. Functor as a generic trait over f<_> is not expressible in stable Rust today. You cannot write:
// This does not compile
trait Functor<A> {
type Mapped<B>: Functor<B>;
fn map<B, F: Fn(A) -> B>(self, f: F) -> Self::Mapped<B>;
}
The type Mapped<B> line is a Generic Associated Type (GAT), which Rust stabilized in version 1.65 (November 2022). GATs let you express type families: associated types that are themselves parameterized. This is a significant step toward HKT simulation, and several lens experiments have leaned on GATs since stabilization.
But GATs do not get you all the way there. The van Laarhoven encoding needs universal quantification over all functors simultaneously within a single function’s type signature. What you can do with GATs is write a trait that simulates a specific functor, then parameterize your lens over that trait. What you cannot easily do is have a single lens value that works for both Identity and Const without either boxing the functor or splitting the lens into separate getter and setter functions.
The boxing route looks like this:
type Lens<S, A> = Box<dyn Fn(Box<dyn Fn(A) -> Box<dyn Any>>) -> Box<dyn Any>>;
That compiles. It also erases all the type information that made lenses useful, and the allocation overhead is real. You have traded the problem for a different, worse problem.
Prior Art in the Ecosystem
The lens-rs crate takes a different approach: it uses procedural macros to derive lens implementations at the field level, and it defines its own optic hierarchy through marker traits. Instead of a single polymorphic function type, it represents lenses as structs that implement GetRef, GetMut, and Set traits. Composition is handled by a dedicated Optic trait with a _mapping associated function.
This works. You get genuine composition, you get reasonable ergonomics for common cases, and derive macros keep the boilerplate down. The cost is that the optic hierarchy is fixed by the library. Adding a new kind of optic, say a filtered traversal or an indexed lens, means extending the library’s trait set, not just writing a new function. The extensibility story is weaker than in Haskell where a new optic type is just a new alias over the same polymorphic function type.
The optics crate goes further toward the Haskell model by leaning on GATs to simulate the functor abstraction. Its Lens type is generic over a Mappable trait with a GAT for the mapped output type. Composition is closer to the van Laarhoven ideal. The tradeoff shows up in bounds complexity: long chains of composed lenses accumulate trait bounds in ways that produce unhelpful error messages and occasionally hit the compiler’s recursion limits.
There are also simpler approaches: the lenses crate represents a lens as a plain struct with two closures, get: Fn(&S) -> &A and set: Fn(S, A) -> S. Composition is manual. You write a compose function that chains the two pairs. This is clear, unsurprising, and limited: it handles simple get/set but does not generalize to traversals, prisms, or other optic types without substantial extra machinery.
What a New Approach Has to Do
The interesting design space sits between the macro-derived approach of lens-rs (ergonomic but closed) and the GAT-based approach of optics (open but complex). A solution that avoids both excessive allocation and excessive bounds accumulation would be genuinely useful.
One direction worth considering is defunctionalization: instead of representing lenses as functions, represent them as data and interpret that data with a dispatch function. You enumerate the optic operations you care about, represent a lens as a composition of named steps, and interpret the whole thing at the call site. This is roughly what the lens-rs derive macros do implicitly, but a more explicit version could be more extensible.
Another direction is embracing the split: accept that a Lens<S, A> in Rust is two separate entities, a getter and a setter, and focus design effort on making their composition ergonomic and their derivation automatic. This is less principled than the Haskell encoding but more honest about what the type system can express.
The profunctor encoding, another optics formulation from the Haskell world, has also been explored. In that encoding, a lens is a Profunctor p => p a b -> p s t, and optic categories correspond to different profunctor subclasses. The encoding has the same HKT problem in Rust, but some researchers have found that the profunctor hierarchy maps more cleanly onto Rust traits than the functor-based one, because profunctors are contravariant in their first argument, which aligns better with Rust’s function types.
What Makes This Worth Following
The repeated reinvention is not a sign that the problem is unsolvable. It is a sign that the design space is genuinely large and that each attempt clarifies what the constraints actually are. GATs opened up new possibilities in 2022 that were not available before, and the Rust type system has been evolving in ways that make HKT simulation incrementally more tractable.
What stays constant is the appeal. Rust’s ownership and borrowing make generic data access patterns awkward in ways that lenses could smooth over, particularly in immutable-update scenarios. If you have a configuration tree you want to update functionally, passing paths-as-values rather than writing nested match arms or chained field accesses is genuinely cleaner. The ergonomic win is real; the encoding challenge is just standing in the way.
The posts that land on Lobsters and gather discussion tend to be the ones that found a concrete tradeoff they are comfortable with and can articulate clearly. Whether that tradeoff is the right one depends on what you are building, how deep your nesting goes, and how much you care about optic extensibility versus optic simplicity. But the conversation keeps moving, and that is worth watching.