lens in Haskell

2026-05-31 · 11 min

A lens is a getter and a setter for one field, packaged as a single value that composes with ordinary function composition. This builds the naive record-of-two- functions version, hits the wall that version has, replaces it with the actual van Laarhoven representation the library uses, and then works through the rest of the optics hierarchy - prisms, traversals, isos, folds - on top of that same representation.

§ 01

The naive representation, and its wall

A lens as a pair of functions: get the field, and set it, returning a new whole value.

data Lens s a = Lens
{ view :: s -> a
, set :: a -> s -> s
}

Two records, and a lens onto one field of each.

data Point = Point { _px :: Double, _py :: Double } deriving Show
data Circle = Circle { _center :: Point, _radius :: Double } deriving Show
px :: Lens Point Double
px = Lens _px (\a s -> s { _px = a })
center :: Lens Circle Point
center = Lens _center (\a s -> s { _center = a })

Composing two of these by hand - the getter composes forwards, the setter composes in a criss-cross that gets uglier with every extra level.

compose :: Lens s a -> Lens a b -> Lens s b
compose outer inner = Lens
{ view = view inner . view outer
, set = \b s -> set outer (set inner b (view outer s)) s
}
centerX :: Lens Circle Double
centerX = compose center px

Using it - reads fine so far.

ghci> let c = Circle (Point 1 2) 5
ghci> view centerX c
1.0
ghci> set centerX 99 c
Circle {_center = Point {_px = 99.0, _py = 2.0}, _radius = 5.0}

The wall: `modify` (update in place, given the old value) has to call view and set separately, which means every update walks the structure twice.

modify :: Lens s a -> (a -> a) -> s -> s
modify l f s = set l (f (view l s)) s

Worse: nothing about this Lens type composes with a Maybe, an Either, a list, or any other functor-shaped access. A lens onto a record field and a prism onto a constructor end up as two unrelated types with no shared vocabulary, and every combinator has to be reinvented per shape.

(* Lens s a composes with Lens a b via the hand-written `compose` above. *)
(* A Prism onto an Either branch, or a Traversal over a list, would need *)
(* its own separate composition function - none of it unifies. *)
§ 02

The van Laarhoven representation

The fix is to stop representing a lens as data, and represent it as a function polymorphic over any Functor. This one change is what lets every optic - lens, prism, traversal, fold - become the same underlying shape, just instantiated at a different functor.

The type. It looks unmotivated until you use it - a lens is a natural transformation lifting a function s -> f s from acting on the field to acting on the whole.

{-# LANGUAGE RankNTypes #-}
type Lens' s a = forall f. Functor f => (a -> f a) -> s -> f s

Building one from the same getter/setter pair as before - lens is the smart constructor that turns familiar get/set into this shape.

lens :: (s -> a) -> (s -> a -> s) -> Lens' s a
lens getter setter afa s = fmap (setter s) (afa (getter s))
px :: Lens' Point Double
px = lens _px (\s a -> s { _px = a })
center :: Lens' Circle Point
center = lens _center (\s a -> s { _center = a })

Composition is now just (.) - ordinary function composition, because a Lens' is just a function. This is the entire payoff of the representation change.

centerX :: Lens' Circle Double
centerX = center . px

view instantiates f at Const a, whose Functor instance ignores the update entirely - running the lens at this functor extracts only the getter behavior.

newtype Const a b = Const { getConst :: a }
instance Functor (Const a) where
fmap _ (Const a) = Const a
view :: Lens' s a -> s -> a
view l s = getConst (l Const s)

set and over instantiate f at Identity, whose Functor instance just applies the function - running the lens at this functor performs the update.

newtype Identity a = Identity { runIdentity :: a }
instance Functor Identity where
fmap f (Identity a) = Identity (f a)
over :: Lens' s a -> (a -> a) -> s -> s
over l f s = runIdentity (l (Identity . f) s)
set :: Lens' s a -> a -> s -> s
set l a = over l (const a)

One walk of the structure does both the read and the write now - view and set were separate walks before; over is a single pass because the functor carries both the extracted value and the rebuilt structure through the same traversal.

ghci> view centerX (Circle (Point 1 2) 5)
1.0
ghci> over centerX (+ 100) (Circle (Point 1 2) 5)
Circle {_center = Point {_px = 101.0, _py = 2.0}, _radius = 5.0}

This is, field for field, lens\'s own Lens type. Everything above is what the library gives you for free.

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

Using the real library

Install it.

$ cabal install lens

makeLenses generates a Lens\' for every underscore-prefixed field automatically, via Template Haskell - the by-hand lens function above is what this expands to.

{-# LANGUAGE TemplateHaskell #-}
import Control.Lens
data Point = Point { _px :: Double, _py :: Double } deriving Show
makeLenses ''Point
data Circle = Circle { _center :: Point, _radius :: Double } deriving Show
makeLenses ''Circle

What it generates - the field names, with the leading underscore stripped.

-- generates:
-- px :: Lens' Point Double
-- py :: Lens' Point Double
-- center :: Lens' Circle Point
-- radius :: Lens' Circle Double

The operators, which is what code actually looks like day to day. ^. is view, .~ is set, %~ is over, all infix.

let c = Circle (Point 1 2) 5
c ^. center . px
-- 1.0
c & center . px .~ 99
-- Circle {_center = Point {_px = 99.0, _py = 2.0}, _radius = 5.0}
c & center . px %~ (+ 100)
-- Circle {_center = Point {_px = 101.0, _py = 2.0}, _radius = 5.0}

& is just reverse function application - reads left to right as 'take c, then apply this lens update', which is the whole reason it shows up so often in lens code.

(&) :: a -> (a -> b) -> b
x & f = f x

Chaining several updates in one expression - each %~/.~ is independent, applied left to right.

c & center . px %~ (+ 1)
& center . py %~ (* 2)
& radius .~ 10

Numeric operators specialized for the common case, so you rarely write %~ (+ n) by hand.

c & center . px +~ 1 -- add
c & center . px -~ 1 -- subtract
c & center . px *~ 2 -- multiply
c & radius //~ 2 -- divide
c & center . px <>~ 0 -- mappend, for Monoid fields
§ 04

Traversals: more than one target

A Lens' always finds exactly one target. A Traversal' generalizes that to zero-or-more, by requiring Applicative instead of just Functor - the extra power buys the ability to sequence effects across multiple targets rather than just one.

The type - one character different from Lens\', and that difference is exactly what allows more or fewer than one focus.

type Traversal' s a = forall f. Applicative f => (a -> f a) -> s -> f s

traverse itself is a Traversal' over every element of any Traversable - Functor is not strong enough to sequence effects across a list of unknown length, which is exactly why Traversal needs Applicative.

let xs = [1, 2, 3, 4, 5] :: [Int]
xs ^.. traverse
-- [1,2,3,4,5] (toListOf, collects every focus)
xs & traverse %~ (* 10)
-- [10,20,30,40,50]
xs ^? traverse
-- Just 1 (preview, the first focus only)

Composing a Lens\' with a Traversal\' focuses every element reachable through the lens - each of a list of circles, updated through the same center . px path used above on one.

let circles = [Circle (Point 1 2) 5, Circle (Point 3 4) 6]
circles ^.. traverse . center . px
-- [1.0, 3.0]
circles & traverse . center . px %~ (+ 100)
-- [Circle {_center = Point {_px = 101.0, ...}, ...}, ...]

filtered narrows a traversal to targets matching a predicate - only radii above 5 get doubled, everything else passes through untouched.

circles & traverse . radius . filtered (> 5) %~ (* 2)

each traverses every field of a tuple uniformly, when they share a type.

(1, 2, 3) & each %~ (* 10)
-- (10, 20, 30)

A worked Traversal\' by hand, for a type outside Traversable - traverse gets replaced with your own function shaped the same way, and it composes with lens/traversal machinery exactly like the library\'s own.

data Pair a = Pair a a deriving Show
both' :: Traversal' (Pair a) a
both' f (Pair x y) = Pair <$> f x <*> f y
Pair 1 2 & both' %~ (* 10)
-- Pair 10 20
§ 05

Prisms: optional, constructor-shaped access

A lens assumes the target always exists. A Prism' is for a sum type\'s constructor, which may or may not match - it can build a value going one direction, and only sometimes deconstruct one going the other.

prism\' takes the constructor and a partial match function returning Maybe - review runs it forwards, preview runs it backwards and fails as Nothing when the shape does not match.

data Shape = SCircle Double | SRect Double Double deriving Show
_SCircle :: Prism' Shape Double
_SCircle = prism' SCircle (\s -> case s of
SCircle r -> Just r
_ -> Nothing)
_SRect :: Prism' Shape (Double, Double)
_SRect = prism' (uncurry SRect) (\s -> case s of
SRect w h -> Just (w, h)
_ -> Nothing)

review builds; preview attempts to extract and yields Maybe.

review _SCircle 5
-- SCircle 5.0
preview _SCircle (SCircle 5)
-- Just 5.0
preview _SCircle (SRect 3 4)
-- Nothing

^? is preview and # is review, as infix operators - matching the ^. / .~ family used for lenses above.

SCircle 5 ^? _SCircle
-- Just 5.0
_SCircle # 5
-- SCircle 5.0

over on a prism only touches the value when the shape matches - it is a no-op, not an error, on a mismatch, which is the entire point of it being optional rather than partial.

SCircle 5 & _SCircle %~ (* 2)
-- SCircle 10.0
SRect 3 4 & _SCircle %~ (* 2)
-- SRect 3.0 4.0 (untouched: the shape did not match)

makePrisms generates one prism per constructor automatically, the same way makeLenses generates one lens per field - _SCircle and _SRect above are exactly what this produces.

data Shape = SCircle Double | SRect Double Double
makePrisms ''Shape

_Just, _Nothing, _Left, _Right are prisms the library ships for the standard sum types, so Maybe and Either slot into the same optics vocabulary with no extra code.

Just 5 ^? _Just
-- Just 5
Left "err" ^? _Left
-- Just "err"
Just 5 & _Just %~ (+ 1)
-- Just 6

Composing a lens with a prism reaches into a field that is itself a sum type, updating only when that field\'s shape matches - a Maybe Point field, focused past both the Maybe and the record in one path.

data Config = Config { _origin :: Maybe Point } deriving Show
makeLenses ''Config
Config (Just (Point 1 2)) & origin . _Just . px %~ (+ 10)
-- Config {_origin = Just (Point {_px = 11.0, _py = 2.0})}
Config Nothing & origin . _Just . px %~ (+ 10)
-- Config {_origin = Nothing} (no-op: nothing to focus on)
§ 06

Isos: lossless, reversible conversions

An Iso\' witnesses that two types are interconvertible with no loss - unlike a lens, which discards nothing but also does not promise reversibility, an iso can be run backwards as freely as forwards.

newtype Celsius = Celsius Double deriving Show
newtype Fahrenheit = Fahrenheit Double deriving Show
celsiusToFahrenheit :: Iso' Celsius Fahrenheit
celsiusToFahrenheit = iso
(\(Celsius c) -> Fahrenheit (c * 9/5 + 32))
(\(Fahrenheit f) -> Celsius ((f - 32) * 5/9))

view runs it forwards; the from combinator flips it and view runs the flipped version backwards - one iso definition serves both directions.

Celsius 100 ^. celsiusToFahrenheit
-- Fahrenheit 212.0
Fahrenheit 32 ^. from celsiusToFahrenheit
-- Celsius 0.0

_Wrapped/_Unwrapped, generated by makeWrapped for a newtype, is the library\'s built-in version of this exact pattern - the unwrap/wrap isomorphism a newtype always has for free.

newtype Age = Age Int deriving (Show)
makeWrapped ''Age
Age 30 ^. _Wrapped
-- 30
(30 :: Int) ^. _Unwrapped'
-- Age 30
§ 07

Folds: read-only, and where they matter

A Fold\' is a Traversal\' restricted to Functor + Contravariant f rather than full Applicative, which is the type-level way of saying it can only be run through view/preview/toListOf, never through set or over.

type Fold' s a = forall f. (Contravariant f, Applicative f) => (a -> f a) -> s -> f s

folded turns an ordinary Foldable into a Fold\' - the read-only counterpart of traverse used above.

circles ^.. folded . radius
-- [5.0, 6.0]
sumOf (folded . radius) circles
-- 11.0
maximumOf (folded . radius) circles
-- Just 6.0

The aggregating combinators built on Fold, which is most of why a Fold exists at all rather than just always using toListOf and a plain list function.

sumOf, productOf numeric aggregation over the targets
lengthOf how many targets a fold has
anyOf, allOf, noneOf boolean aggregation with a predicate
minimumOf, maximumOf Maybe-wrapped, since the fold may be empty
toListOf (= (^..)) every target, as a plain list
firstOf, lastOf Maybe-wrapped edges

to lifts an ordinary function into a Fold\', so you can splice a plain computation into an optics chain without leaving it.

circles ^.. folded . to (\c -> _radius c * 2)
-- [10.0, 12.0]
§ 08

Indexed optics

itraverse and ifolded carry the position alongside the value - the list index, the map key - without a separate zip against [0..] or Map.keys.

let xs = ["a", "b", "c"]
xs ^@.. itraversed
-- [(0,"a"), (1,"b"), (2,"c")]
xs & itraversed %@~ (\i s -> show i <> ":" <> s)
-- ["0:a", "1:b", "2:c"]

At for Map, both reading and inserting/deleting through the same optic - at k returns a Lens\' onto a Maybe, so setting it to Nothing deletes the key.

import qualified Data.Map as Map
import Control.Lens.At
let m = Map.fromList [("x", 1), ("y", 2)] :: Map.Map String Int
m ^. at "x"
-- Just 1
m & at "z" ?~ 3
-- fromList [("x",1),("y",2),("z",3)]
m & at "x" .~ Nothing
-- fromList [("y",2)]

ix, by contrast, is a Traversal\' - a no-op on a missing key rather than an insertion point, since it cannot create the position at k the way at can.

m & ix "x" %~ (+ 100)
-- fromList [("x",101),("y",2)]
m & ix "missing" %~ (+ 100)
-- fromList [("x",1),("y",2)] (no-op)
§ 09

Generic derivation without Template Haskell

generic-lens derives the same lenses via GHC.Generics rather than Template Haskell - no makeLenses call, no compile-time code generation step to wait on, at the cost of losing the underscore-stripped naming convention.

$ cabal install generic-lens

field\' looks a field up by its type-level Symbol name - the record needs no special preparation beyond deriving Generic.

{-# LANGUAGE DeriveGeneric, DataKinds #-}
import Data.Generics.Product (field')
import GHC.Generics (Generic)
data Point = Point { px :: Double, py :: Double }
deriving (Show, Generic)
Point 1 2 ^. field' @"px"
-- 1.0
Point 1 2 & field' @"px" %~ (+ 10)
-- Point {px = 11.0, py = 2.0}
§ 10

A realistic composite: JSON, indexed, prism, traversal

lens-aeson gives Value its own optics vocabulary, so navigating parsed JSON reads exactly like navigating a record - the same %~/.~/^. family, over a dynamically typed tree instead of a static one.

$ cabal install lens-aeson

Reaching into nested, partially-unknown JSON - key for an object field, _String/_Integer as prisms onto a JSON scalar\'s shape, nth for an array index.

import Data.Aeson.Lens
import Data.Aeson (Value, decode)
let Just v = decode
"{\"users\":[{\"name\":\"ada\",\"age\":30},
{\"name\":\"grace\",\"age\":45}]}" :: Maybe Value
v ^.. key "users" . values . key "name" . _String
-- ["ada", "grace"]
v ^? key "users" . nth 0 . key "age" . _Integer
-- Just 30
v & key "users" . values . key "age" . _Integer %~ (+ 1)
-- every user's age incremented by one, structure otherwise untouched

Composed with itraversed to know which user is being touched while touching them, tying together indexed optics, prisms, and traversals in one expression.

v ^@.. key "users" . values . itraversed . key "name" . _String
-- [(0,"ada"), (1,"grace")]

Most of the library reduces to composing four things: a Lens for one required field, a Prism for one branch that may not match, a Traversal for zero or more, and an Iso when the conversion is lossless both ways. Generic derivation gets you most of this for free, the same way Deriving Strategies in Haskell gets you the rest of a typeclass instance.