Project 11: Lenses and Optics Library

Project 11: Lenses and Optics Library

Conquering Nested Data with Composable Accessors

  • Main Programming Language: Haskell
  • Alternative Languages: Scala (Monocle), PureScript, TypeScript (monocle-ts)
  • Coolness Level: Level 4: Hardcore Tech Flex
  • Difficulty: Level 4: Expert
  • Knowledge Area: Data Access / Optics / Category Theory
  • Estimated Time: 3-4 weeks
  • Prerequisites: Projects 5 (Algebraic Data Types), Project 6 (Functor/Applicative/Monad), comfort with higher-order functions

Learning Objectives

After completing this project, you will be able to:

  1. Implement the Lens type from first principles - Build lenses as pairs of getters and setters, then upgrade to Van Laarhoven representation
  2. Understand why lenses compose with function composition - Explain the type-level magic that makes (.) work for lenses
  3. Implement the optics hierarchy - Build Lens, Prism, Traversal, Iso, and understand their relationships
  4. Use different functors for different operations - See how Const extracts values and Identity updates them
  5. Apply lenses to real-world data transformation - Handle deeply nested JSON, configuration, and domain models
  6. Connect lenses to category theory - Understand profunctor optics and the deeper mathematical structure
  7. Design composable data access patterns - Build libraries that are both powerful and ergonomic

Conceptual Foundation

The Problem: Updating Nested Immutable Data

In imperative programming, updating a field in a nested object is trivial:

// JavaScript: easy peasy
person.address.city = "New York";

But in pure functional programming, data is immutable. You can’t mutate; you must create new values. The naive approach is painful:

-- Haskell: painful boilerplate
data Person = Person { _name :: String, _address :: Address }
data Address = Address { _city :: String, _street :: String }

updateCity :: String -> Person -> Person
updateCity newCity person =
  person { _address = (_address person) { _city = newCity } }

This gets exponentially worse with deeper nesting:

-- Three levels deep...
updateZipCode :: String -> Company -> Company
updateZipCode newZip company =
  company {
    _headquarters = (_headquarters company) {
      _location = (_location (_headquarters company)) {
        _zipCode = newZip
      }
    }
  }
-- Nightmare fuel!

Lenses solve this problem elegantly.

What Is a Lens?

At its core, a lens is a first-class accessor—a value that represents the ability to focus on a part of a larger structure. It combines:

  1. A getter: Extract the focused value from the whole
  2. A setter: Replace the focused value in the whole
-- Simple lens representation
data Lens' s a = Lens'
  { view :: s -> a        -- Get the 'a' from an 's'
  , set  :: a -> s -> s   -- Set the 'a' in an 's', return new 's'
  }

With this, we can create lenses for each field:

address :: Lens' Person Address
address = Lens' _address (\newAddr person -> person { _address = newAddr })

city :: Lens' Address String
city = Lens' _city (\newCity addr -> addr { _city = newCity })

Why Lenses Must Compose

The key insight is that lenses should compose like functions. If you have:

  • A lens from Person to Address
  • A lens from Address to City

You should be able to compose them to get a lens from Person to City:

personCity :: Lens' Person String
personCity = address `composeLens` city

Let’s implement composition for our simple lens:

composeLens :: Lens' a b -> Lens' b c -> Lens' a c
composeLens (Lens' viewAB setAB) (Lens' viewBC setBC) = Lens'
  { view = viewBC . viewAB  -- Get B from A, then C from B
  , set = \c a ->
      let b = viewAB a           -- Get current B from A
          b' = setBC c b         -- Update C in B, get new B
      in setAB b' a              -- Update B in A, get new A
  }

This works! But notice the complexity—we’re doing the work of composition manually. There’s a better way.

The Van Laarhoven Representation: Where Magic Happens

In 2009, Twan van Laarhoven discovered an encoding of lenses that makes composition automatic. Instead of storing getter and setter separately, represent a lens as a single higher-order function:

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

What does this type mean?

Read it as: “Given a way to wrap and transform the focused part (a -> f b), produce a way to wrap and transform the whole (s -> f t).”

The forall f. Functor f => means this works for any functor f. This universality is the key to its power.

Building a Van Laarhoven Lens

-- The lens constructor
lens :: (s -> a) -> (s -> b -> t) -> Lens s t a b
lens getter setter = \f s -> setter s <$> f (getter s)
--                            |___________|
--                            Update the structure with the new focused value
--                                  |__________|
--                                  Apply f to the focused value

Let’s trace through this:

  1. getter s extracts the focused value from s
  2. f (getter s) wraps/transforms it using the functor
  3. setter s <$> ... maps the setter over the result, producing f t

Example:

_address :: Lens' Person Address
_address = lens _address (\person newAddr -> person { _address = newAddr })

_city :: Lens' Address String
_city = lens _city (\addr newCity -> addr { _city = newCity })

Why Composition Becomes Free

Here’s the magic: with Van Laarhoven encoding, lens composition is just function composition!

_personCity :: Lens' Person String
_personCity = _address . _city

How does this work? Let’s trace the types:

_address :: (Address -> f Address) -> Person -> f Person
_city    :: (String -> f String) -> Address -> f Address

-- Composing with (.)
_address . _city :: (String -> f String) -> Person -> f Person

When you compose _address . _city:

  1. _city takes (String -> f String) and produces (Address -> f Address)
  2. _address takes (Address -> f Address) and produces (Person -> f Person)
  3. The composition takes (String -> f String) and produces (Person -> f Person)

It’s a lens from Person to String!

The functor parameter threads through automatically. This is why the Van Laarhoven encoding is so elegant—it piggybacks on existing function composition.

Using Different Functors for Different Operations

The forall f. Functor f => lets us choose different functors for different purposes:

Getting with Const

newtype Const r a = Const { getConst :: r }

instance Functor (Const r) where
  fmap _ (Const r) = Const r  -- Ignores the function, keeps the value

To get a value through a lens:

view :: Lens' s a -> s -> a
view l s = getConst (l Const s)

Trace: l Const s applies the lens with Const as the functor:

  1. l calls f (getter s) where f = Const
  2. Const (getter s) wraps the focused value
  3. setter s <$> Const (getter s) = Const (getter s) (because fmap on Const ignores the function)
  4. getConst extracts the focused value

We’ve extracted a value without ever using the setter!

Setting with Identity

newtype Identity a = Identity { runIdentity :: a }

instance Functor Identity where
  fmap f (Identity a) = Identity (f a)

To set a value through a lens:

set :: Lens' s a -> a -> s -> s
set l newVal s = runIdentity (l (\_ -> Identity newVal) s)

Trace:

  1. l receives (\_ -> Identity newVal) as the “focusing function”
  2. l applies this to getter s, ignoring the old value
  3. setter s <$> Identity newVal = Identity (setter s newVal)
  4. runIdentity extracts the result

Modifying with Identity

over :: Lens' s a -> (a -> a) -> s -> s
over l f s = runIdentity (l (Identity . f) s)

This applies a function to the focused value and puts it back.

The Optics Hierarchy

Lenses are just one member of a family of optics. Each focuses on different patterns:

           Getter
             |
    Fold <---+---> Lens
      |             |
      |      +------+------+
      |      |             |
      +-> Traversal      Prism
              |             |
              +-----+-------+
                    |
                  Affine
                    |
                    |
                   Iso

Optics Hierarchy

Lens: Focus on Exactly One Value

type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t
  • Always has exactly one target
  • Can always get and set
  • Example: A field in a record

Prism: Focus on Zero or One Value (Sum Types)

type Prism s t a b = forall p f. (Choice p, Applicative f) => p a (f b) -> p s (f t)

A prism is for sum types where the value might not exist:

_Left :: Prism (Either a c) (Either b c) a b
_Left = prism Left $ \case
  Left a  -> Right a   -- Found it
  Right c -> Left (Right c)  -- Not here

-- Using a prism
preview _Left (Left 5)   -- Just 5
preview _Left (Right 5)  -- Nothing

Prisms are partial lenses—they might fail to find a value.

Traversal: Focus on Zero or More Values

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

Traversals focus on multiple values at once:

-- Focus on all elements of a list
traverse :: Traversal [a] [b] a b

-- Use it to modify all elements
over traverse (*2) [1,2,3]  -- [2,4,6]

-- Get all elements (as a list)
toListOf traverse [1,2,3]   -- [1,2,3]

Why Applicative instead of Functor?

With multiple targets, we need to combine effects. Applicative provides <*> for this:

traverse f [] = pure []
traverse f (x:xs) = (:) <$> f x <*> traverse f xs
--                       ^^^ ^^^
--                       Combining two effects

Iso: Lossless Conversion

type Iso s t a b = forall p f. (Profunctor p, Functor f) => p a (f b) -> p s (f t)

An isomorphism is a lens that can go both ways with no loss:

_Reversed :: Iso' [a] [a]
_Reversed = iso reverse reverse

view _Reversed [1,2,3]      -- [3,2,1]
set _Reversed [3,2,1] [1,2,3]  -- [1,2,3]

Common isos:

  • _Wrapped :: Iso' (Identity a) a
  • _Swapped :: Iso' (a, b) (b, a)
  • _Chars :: Iso' String [Char]

The Profunctor Perspective

For a deeper understanding, lenses can be viewed through profunctors:

class Profunctor p where
  dimap :: (a' -> a) -> (b -> b') -> p a b -> p a' b'

A profunctor is like a function that can be modified on both input and output. Regular functions form a profunctor:

instance Profunctor (->) where
  dimap f g h = g . h . f  -- pre-compose with f, post-compose with g

Profunctor optics generalize Van Laarhoven optics:

type Optic p s t a b = p a b -> p s t

Different constraints on p give different optics:

  • Profunctor p => Adapter
  • Strong p => Lens
  • Choice p => Prism
  • Traversing p => Traversal

This is the foundation of modern optics libraries like profunctors and optics.

Lens Laws: Guarantees of Well-Behavedness

A proper lens must satisfy three laws:

1. Get-Put (ViewSet): If you get a value and set it back, nothing changes.

set l (view l s) s == s

2. Put-Get (SetView): If you set a value and get it, you get what you set.

view l (set l a s) == a

3. Put-Put (SetSet): Setting twice is the same as setting once (with the second value).

set l a' (set l a s) == set l a' s

These laws ensure lenses behave like “proper” accessors. Violating them leads to surprising behavior.

Example of a law-violating “lens”:

-- Bad lens: counts how many times it's used
badLens :: IORef Int -> Lens' s a
-- Violates Put-Put: each set has side effects

Composing Different Optics

One of the beautiful properties of optics is that different types of optics compose:

-- Lens . Traversal = Traversal
-- Lens . Prism = Affine (Traversal)
-- Traversal . Traversal = Traversal
-- Prism . Lens = Affine
-- Prism . Prism = Prism

The result is always the “weakest” of the two:

-- Focusing on the city of each employee
employeesCities :: Traversal' Company String
employeesCities = employees . traverse . address . city
--                  Lens      Traversal  Lens     Lens
--                           Result: Traversal

This is why the optics hierarchy forms a lattice—composition moves you toward less powerful (but more general) optics.

Real-World Applications

JSON Processing

-- Deeply nested JSON
json ^. key "users" . nth 0 . key "profile" . key "email"

-- Modify deeply nested value
json & key "users" . traverse . key "status" .~ "active"

Game State

-- Game with player, inventory, equipment
data Game = Game { _player :: Player, _world :: World }
data Player = Player { _health :: Int, _inventory :: [Item] }
data Item = Item { _name :: String, _durability :: Int }

-- Give all items +10 durability
game & player . inventory . traverse . durability +~ 10

Configuration

-- Type-safe configuration access
config ^. section "database" . setting "host"

-- Modify with defaults
config & section "logging" . setting "level" %~ fromMaybe "info"

The Deep Connection to Category Theory

Lenses connect to profound mathematical structures:

Lenses as Coalgebras: A lens Lens' s a is equivalent to s -> (a, a -> s). This is a coalgebra for the costate comonad!

Profunctor Optics and Day Convolution: The full theory of profunctor optics involves concepts from category theory like Day convolution and ends/coends.

Lenses and Dependent Types: In dependently typed languages, lenses generalize to higher-order lenses that can change the type of what they focus on.

For this project, you don’t need to understand all the category theory, but knowing it exists gives context for why lenses are so powerful.

Why Lenses Changed Haskell

Before lenses (pre-2012), Haskell code involving nested data was verbose and error-prone. The lens library by Edward Kmett unified years of research into a practical tool.

Today, lenses are used throughout the Haskell ecosystem:

  • aeson: JSON handling with lenses
  • servant: Type-safe web APIs
  • reflex: Functional reactive programming
  • optics: Modern reimagining of lenses

Understanding lenses is essential for production Haskell.


Project Specification

You will build an optics library from scratch. Your library should support:

Core Requirements

  1. Simple Lenses
    • Lens' type (simplified, same source and target types)
    • Constructor function: lens
    • Operations: view, set, over
    • Composition with (.)
  2. Van Laarhoven Lenses
    • Full Lens s t a b type
    • Proper use of forall f. Functor f =>
    • Same operations, but more general
  3. Basic Optics Hierarchy
    • Lens: Focus on one value (Functor constraint)
    • Traversal: Focus on multiple values (Applicative constraint)
    • Prism: Focus on sum types (Choice constraint)
    • Iso: Lossless conversion
  4. Operators
    • (^.) for view
    • (.~) for set
    • (%~) for over
    • (&) for reverse application
  5. Combinators
    • to: Read-only lens from a function
    • folded: Traversal over Foldable
    • _1, _2: Tuple lenses
    • _head, _tail: List lenses

Stretch Goals

  • Prism construction: prism and prism'
  • At and Ix type classes for container access
  • State monad integration: use, .=, %=
  • Indexed optics: Traversals that carry indices
  • TH-based lens generation (like makeLenses)

Solution Architecture

Module Structure

src/
  Optics/
    Lens.hs       -- Core lens types and operations
    Prism.hs      -- Prism type and constructors
    Traversal.hs  -- Traversal type and combinators
    Iso.hs        -- Isomorphism type
    Operators.hs  -- Infix operators
    Combinators.hs -- Utility combinators
    At.hs         -- Container access type classes
  Optics.hs       -- Re-exports public API

Core Types

-- The lens type family
type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t
type Lens' s a = Lens s s a a

-- Traversal: multiple targets
type Traversal s t a b = forall f. Applicative f => (a -> f b) -> s -> f t
type Traversal' s a = Traversal s s a a

-- Prism: partial/sum type access
type Prism s t a b = forall p f. (Choice p, Applicative f) => p a (f b) -> p s (f t)
type Prism' s a = Prism s s a a

-- Iso: bidirectional conversion
type Iso s t a b = forall p f. (Profunctor p, Functor f) => p a (f b) -> p s (f t)
type Iso' s a = Iso s s a a

-- Getter: read-only
type Getter s a = forall f. (Contravariant f, Functor f) => (a -> f a) -> s -> f s

-- Fold: multiple read-only
type Fold s a = forall f. (Contravariant f, Applicative f) => (a -> f a) -> s -> f s

Helper Types

-- For extracting values (getting)
newtype Const r a = Const { getConst :: r }

-- For updating values (setting)
newtype Identity a = Identity { runIdentity :: a }

-- For profunctor optics
class Profunctor p where
  dimap :: (a' -> a) -> (b -> b') -> p a b -> p a' b'

class Profunctor p => Choice p where
  left' :: p a b -> p (Either a c) (Either b c)
  right' :: p a b -> p (Either c a) (Either c b)

Implementation Guide

Phase 1: Simple Lenses (Days 1-3)

Goal: Implement lenses as getter/setter pairs.

Milestone 1.1: Define the simple lens type

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

Milestone 1.2: Implement over

over :: SimpleLens s a -> (a -> a) -> s -> s
over l f s = set l (f (view l s)) s

Milestone 1.3: Implement composition

composeLens :: SimpleLens a b -> SimpleLens b c -> SimpleLens a c

Milestone 1.4: Create lenses for sample data types

data Person = Person { personName :: String, personAddress :: Address }
data Address = Address { addressCity :: String, addressStreet :: String }

name :: SimpleLens Person String
address :: SimpleLens Person Address
city :: SimpleLens Address String

Test: Verify get/set/over work, verify composition works.

Phase 2: Van Laarhoven Encoding (Days 4-7)

Goal: Upgrade to the Van Laarhoven representation.

Milestone 2.1: Define helper types

newtype Const r a = Const { getConst :: r }
newtype Identity a = Identity { runIdentity :: a }

instance Functor (Const r) where ...
instance Functor Identity where ...

Milestone 2.2: Define the lens type

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

Milestone 2.3: Implement the lens constructor

lens :: (s -> a) -> (s -> b -> t) -> Lens s t a b
lens getter setter = \f s -> setter s <$> f (getter s)

Milestone 2.4: Implement view, set, over

view :: Lens' s a -> s -> a
view l s = getConst (l Const s)

set :: Lens s t a b -> b -> s -> t
set l b s = runIdentity (l (\_ -> Identity b) s)

over :: Lens s t a b -> (a -> b) -> s -> t
over l f s = runIdentity (l (Identity . f) s)

Milestone 2.5: Verify composition works with (.)

-- This should work automatically!
personCity :: Lens' Person String
personCity = address . city

Test: Same tests as Phase 1, verify (.) works.

Phase 3: Operators and Ergonomics (Days 8-10)

Goal: Add operators for fluent lens usage.

Milestone 3.1: Basic operators

(^.) :: s -> Lens' s a -> a
s ^. l = view l s

(.~) :: Lens s t a b -> b -> s -> t
l .~ b = set l b

(%~) :: Lens s t a b -> (a -> b) -> s -> t
l %~ f = over l f

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

Milestone 3.2: Usage patterns

-- Fluent reading
person ^. address . city

-- Fluent updating
person & address . city .~ "Boston"

-- Fluent modifying
person & address . city %~ map toUpper

Milestone 3.3: Additional operators

(+~) :: Num a => Lens' s a -> a -> s -> s
(-~) :: Num a => Lens' s a -> a -> s -> s
(*~) :: Num a => Lens' s a -> a -> s -> s
(//~) :: Fractional a => Lens' s a -> a -> s -> s

Test: Write expressive code using operators.

Phase 4: Traversals (Days 11-14)

Goal: Implement traversals for multiple targets.

Milestone 4.1: Define the traversal type

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

Milestone 4.2: Implement traversal operations

toListOf :: Traversal' s a -> s -> [a]
over :: Traversal s t a b -> (a -> b) -> s -> t  -- Same signature as Lens!

Milestone 4.3: Build common traversals

traverse :: Traversable t => Traversal (t a) (t b) a b
both :: Traversal (a, a) (b, b) a b
each :: Traversal [a] [b] a b

Milestone 4.4: Compose lenses and traversals

-- Lens . Traversal = Traversal
allCities :: Traversal' Company String
allCities = employees . traverse . address . city

Test: Use traversals to update multiple values.

Phase 5: Prisms (Days 15-18)

Goal: Implement prisms for sum types.

Milestone 5.1: Define profunctor classes

class Profunctor p where
  dimap :: (a' -> a) -> (b -> b') -> p a b -> p a' b'

class Profunctor p => Choice p where
  left' :: p a b -> p (Either a c) (Either b c)

Milestone 5.2: Define the prism type

type Prism s t a b = forall p f. (Choice p, Applicative f) => p a (f b) -> p s (f t)

Milestone 5.3: Implement prism constructor

prism :: (b -> t) -> (s -> Either t a) -> Prism s t a b

Milestone 5.4: Implement prism operations

preview :: Prism' s a -> s -> Maybe a
review :: Prism' s a -> a -> s

Milestone 5.5: Build common prisms

_Left :: Prism (Either a c) (Either b c) a b
_Right :: Prism (Either c a) (Either c b) a b
_Just :: Prism (Maybe a) (Maybe b) a b
_Nothing :: Prism' (Maybe a) ()

Test: Use prisms to work with Either and Maybe.

Phase 6: Isos and Polish (Days 19-21)

Goal: Implement isomorphisms and polish the library.

Milestone 6.1: Define the iso type

type Iso s t a b = forall p f. (Profunctor p, Functor f) => p a (f b) -> p s (f t)

Milestone 6.2: Implement iso constructor

iso :: (s -> a) -> (b -> t) -> Iso s t a b

Milestone 6.3: Implement iso operations

from :: Iso s t a b -> Iso b a t s  -- Flip direction

Milestone 6.4: Build common isos

_Reversed :: Iso' [a] [a]
_Swapped :: Iso (a, b) (c, d) (b, a) (d, c)
curried :: Iso ((a, b) -> c) ((d, e) -> f) (a -> b -> c) (d -> e -> f)

Test: Use isos for bidirectional transformations.


Testing Strategy

Property-Based Tests (Using Project 10!)

-- Get-Put law
prop_getLaw :: Person -> Bool
prop_getLaw p = set _name (view _name p) p == p

-- Put-Get law
prop_putGetLaw :: Person -> String -> Bool
prop_putGetLaw p name = view _name (set _name name p) == name

-- Put-Put law
prop_putPutLaw :: Person -> String -> String -> Bool
prop_putPutLaw p n1 n2 = set _name n2 (set _name n1 p) == set _name n2 p

Unit Tests

-- Composition
test_composition = do
  let person = Person "Alice" (Address "Boston" "Main St")
  assertEqual "NYC" $ view (address . city) person & address . city .~ "NYC" & view (address . city)

-- Traversal
test_traversal = do
  assertEqual [2,4,6] $ over traverse (*2) [1,2,3]
  assertEqual [1,2,3] $ toListOf traverse [1,2,3]

Common Pitfalls

1. Forgetting the Functor Constraint

Problem: Writing a lens without the constraint:

-- WRONG
myLens :: (a -> b) -> s -> t  -- Missing Functor!

Solution: Always include forall f. Functor f =>.

2. Wrong Order in Lens Composition

Problem: Composing in the wrong order:

-- WRONG: This doesn't make sense
city . address  -- Types don't line up!

Solution: Compose from outer to inner: address . city.

3. Using Lens Operations on Traversals

Problem: Using view on a traversal:

view traverse [1,2,3]  -- Which one? Doesn't compile!

Solution: Use toListOf or preview for traversals.

4. Creating Law-Violating Lenses

Problem: A lens that doesn’t satisfy the laws:

badLens = lens getter (\_ _ -> defaultValue)  -- Put-Get violation!

Solution: Always test your lenses against the lens laws.

5. Mixing Up Lens and Prism Operations

Problem: Using set on a prism expects totality:

set _Just x Nothing  -- What should this return?

Solution: Use review for prisms to construct, over for modification.


Extensions and Challenges

1. Indexed Optics

Add index tracking to traversals:

itraverse :: IndexedTraversal Int [a] [b] a b
-- Access both the element and its index

2. At and Ix Type Classes

class At m where
  at :: Index m -> Lens' m (Maybe (IxValue m))

class Ixed m where
  ix :: Index m -> Traversal' m (IxValue m)

3. State Monad Integration

use :: Lens' s a -> State s a
(.=) :: Lens' s a -> a -> State s ()
(%=) :: Lens' s a -> (a -> a) -> State s ()

4. Template Haskell Lens Generation

makeLenses ''Person
-- Generates: name :: Lens' Person String, address :: Lens' Person Address

5. Affine Traversals

Implement 0-or-1 target optics (between Prism and Traversal).


Real-World Connections

Where Lenses Appear in Industry

  1. Web Development: JSON API responses with deeply nested data
  2. Game Development: Complex game state (player, world, inventory)
  3. Configuration Management: Type-safe config access
  4. Database ORMs: Mapping between database rows and domain types
  5. Financial Systems: Complex nested financial instruments

Libraries and Frameworks Using Lenses

  • lens: The original, comprehensive Haskell library
  • optics: Modern redesign with better type errors
  • Monocle: Scala’s optics library
  • monocle-ts: TypeScript optics
  • ramda: JavaScript with lens-like utilities

Interview Questions

  1. “What is a lens and why would you use one?”
    • Answer: A lens is a composable accessor for nested data. In pure FP, updating nested structures without mutation is verbose. Lenses make it elegant by combining getters and setters into first-class values that compose.
  2. “Explain the Van Laarhoven lens representation.”
    • Answer: Instead of storing getter/setter separately, represent a lens as forall f. Functor f => (a -> f b) -> s -> f t. This makes composition automatic (just function composition) and allows different functors for different operations.
  3. “What’s the difference between a Lens and a Traversal?”
    • Answer: A Lens focuses on exactly one value (Functor constraint). A Traversal focuses on zero or more values (Applicative constraint). A Lens is a special case of a Traversal.
  4. “How do you use the Const functor with lenses?”
    • Answer: Const ignores updates and just carries a value. Using Const with a lens extracts the focused value without modification—this is how view works.
  5. “What are the lens laws and why do they matter?”
    • Answer: Get-Put, Put-Get, and Put-Put ensure lenses behave like proper accessors. Violating them leads to surprising behavior where setting then getting doesn’t return what you set.
  6. “What is a Prism and when would you use one?”
    • Answer: A Prism focuses on one branch of a sum type (like Either or Maybe). Use it when the target might not exist. Unlike lenses, prisms can fail to find a value.
  7. “How would you implement over for a Van Laarhoven lens?”
    • Answer: over l f s = runIdentity (l (Identity . f) s). Use the Identity functor with the modifying function to apply it to the focused value.

Self-Assessment Checklist

You’ve mastered this project when you can:

  • Explain why nested immutable updates are painful without lenses
  • Implement simple lenses with getter/setter pairs
  • Upgrade to Van Laarhoven representation and explain why it’s better
  • Implement view, set, and over using different functors
  • Compose lenses using regular function composition
  • Implement Const and Identity functors correctly
  • Build traversals for lists and other traversable structures
  • Implement prisms for sum types like Either and Maybe
  • Explain and test the lens laws
  • Use operators for fluent lens syntax
  • Compose different optics (lens with traversal, etc.)
  • Explain the connection to category theory (at a high level)

Resources

Primary References

Topic Resource Specific Section
Comprehensive lens guide “Optics By Example” by Chris Penner Full book
Van Laarhoven encoding “Lenses, Folds, and Traversals” by Edward Kmett Video/slides
Functor and Applicative “Haskell Programming from First Principles” Chapters 16-17
Higher-order functions “Learn You a Haskell for Great Good!” Chapter 6
Type-level programming “Thinking with Types” by Sandy Maguire Chapter 6

Academic Papers

Topic Paper
Original Van Laarhoven “CPS based functional references”
Profunctor optics “Profunctor Optics: Modular Data Accessors”
Algebraic optics “Categories of Optics”

Online Resources

  • lens library documentation on Hackage
  • “lens over tea” blog series
  • Edward Kmett’s lens talks on YouTube
  • Haskell Wiki on lenses

“The goal of a lens is to make the complex simple. Once you understand them, you’ll wonder how you ever lived without them.”

After completing this project, nested data manipulation will never frustrate you again.