Lens Guide

A lens is a first-class getter/setter for a part A of a whole S. It lets you read, replace, and transform a nested field without hand-writing the rebuild-the-whole boilerplate at every use site. Turmeric ships lenses in stdlib/lens.tur.

(load "stdlib/lens.tur")

(defstruct Point :copy :heap [x : int y : int])

(defn point-x [] : (Lens Point int)
  (lens
    (fn [p : Point] : int (.x p))
    (fn [nx : int p : Point] : Point (make-struct Point :x nx :y (.y p)))))

(defn main [] : int
  (let [px (point-x)
        p  (make-struct Point :x 3 :y 9)]
    (println (view px p))                                     ; 3
    (println (.x (set px 42 p)))                              ; 42
    (println (.x (over px (fn [v : int] : int (* v 10)) p)))) ; 30
  0)

The API

Combinator Type Meaning
lens (fn [S] A) -> (fn [A S] S) -> (Lens S A) build a lens from a getter and a setter
view (Lens S A) -> S -> A read the focused A
set (Lens S A) -> A -> S -> S replace the focused A
over (Lens S A) -> (fn [A] A) -> S -> S map a function over the focused A

view l s is ((.lget l) s); set l a s is ((.lput l) a s); over l f s is set l (f (view l s)) s.

Value semantics

A Lens is :copy, so a single lens value can be view/set/over'd repeatedly. The whole S a lens focuses is usually a :copy struct too, so it can be reused across calls; if the setter rebuilds the record (the common case), make it :copy :heap so it is pointer-carried and cheap to thread. A move-only S can still be used, but only linearly (each whole consumed once).

The two encodings

Turmeric supports two lens encodings. The profunctor-by-record form ships as the default in stdlib/lens.tur and needs no experiment flags. The van Laarhoven form -- the classic Haskell optic

type Lens s a = forall f. Functor f => (a -> f a) -> (s -> f s)

-- now works out of the box: the kind/constraint/higher-rank machinery it needs (the forall-kinds, forall-constraints, hkt-hrt, and hrt-curried-result experiments) and the runtime dictionary passing it relies on (the forall-dict-pass experiment) have all graduated to always-on, so no experiment flags are required. It supports view/set/over, generic focus inference, and composition with ordinary function composition. It carries the caller's Functor dictionary through the poly carrier at runtime, so the lens body dispatches fmap on whichever instance the caller picks (Const for view, Identity for set/over).

The record encoding stays the default because it needs no experiments to run and it works with any Functor-like use, not just those whose (f a) fits the poly carrier. See Functor width below.

Composition -- the one tradeoff of the record form

The record encoding gives up composing optics with ordinary function composition. A generic lens-compose would need its setter to read the whole s (to view the intermediate part) and write it back -- using s twice -- which the linearity checker rejects for an abstract move-only S. Compose by hand at concrete, copyable whole types:

;; Line -> start:Point -> x:int
(defn line-start-x [] : (Lens Line int)
  (lens
    (fn [l : Line] : int (.x (.start l)))
    (fn [nx : int l : Line] : Line
      (make-struct Line
        :start (make-struct Point :x nx :y (.y (.start l)))
        :end   (.end l)))))

where the whole types (Line, Point) are :copy so l/s can be used more than once.

The van Laarhoven form composes freely (l1 . l2 is just function composition); reach for it when you need optic composition.

Functor width

The van Laarhoven form threads (f a) through a one-int64 poly carrier. Two shapes of functor work through that carrier:

The zero-overhead by-value path (Path B) graduated 2026-07-05 and is now always on -- no flag. Every lens call site whose lens statically and uniquely resolves to a simple lens is redirected to a by-value monomorphized body that spells (f a) by value with no heap box on either the (f S) result or the (f A) functor-wrapping result. A consumer lens param that resolves to several distinct simple lenses gets one box-free clone per lens (consumer monomorphization). Two shapes still ride the boxed Path A carrier bridge as a correctness fallback:

See ../upcoming/van-laarhoven-monomorphization-plan.md and ../upcoming/van-laarhoven-consumer-mono-plan.md (Path B).