← The refactoring catalogue · 2 of 35

A mutable field is a place where a class’s invariants can be broken: any method can read it, any method can write it, and nothing stops a write from putting the object into a state the other methods did not anticipate. The object-oriented ladder up this smell is well worn — Fowler’s Encapsulate Variable (the catalogue entry that used to be Encapsulate Field, and before that Self-Encapsulate Field) hides the field behind accessor methods [1], [2], and Remove Setting Method then deletes the setter once the field no longer needs to be written from outside the class [3]. The field is still a field; it is just reached through a corridor.

The functional reading starts at the same smell and goes further out. A field of an immutable record is a projection — a way to reach into a value — and the useful thing about the world of optics is that these projections compose. A lens reaches one field of a record, a prism one branch of a sum, a traversal every element of a container, and the projection you need for a job is built by chaining smaller ones: prism .andThen lens, each .andThen prism .andThen lens. That separation is the whole point — it splits how do I get to field A from what should I do once I have it — so the same tiny optic is reused on one node, on every node of a tree, and against a branch of a sum inside a list, without being rewritten. Replacing a mutable field with a lens means making the record immutable and routing every read through get and every write through set (or modify, which reads, applies a pure function and writes in one step). The state transition is a value again, and the optic combinators are lawful by construction — the contract that makes a rewrite a refactoring is built in, not hoped for — so correctness comes for free; the libraries ship law-solvers that confirm any optic you write by hand [4], [11].

The inverse direction is justified when the decoupling does not earn its keep: a lens that is never composed, a projection whose separation of how from what no one exploits, or — the strongest smell — a codebase with no cross-domain boundaries, where the whole record travels everywhere and only the target of the projection would ever need to pass across a seam. Inlining the lens — replacing get, set and modify at their use sites with direct field access — removes indirection that no longer names anything. The catalogue presents the two directions as one equation, readable both ways; which way you go records a judgement about whether the field is part of a path or a leaf. The eo cookbook is a worked, tested reference for the optics side of this: three jobs optics do best — navigate structures, decouple modules, thread effects — each recipe runnable against the library.

Motivation

Reach for the lens when access to a field needs to be decoupled from its manipulation. The smell is multiple long methods that do too much — the complexity of reaching the values is mixed up with the code for the changes you want to make, and each operation re-derives its own path. A lens separates the two: how you get to a value and what you do once you have it stop being one entangled method. The optics version of the corridor is a value, so it can be passed around, stored, and composed into paths that reach several levels down without ever repeating the intermediate records; and a function that needs “every Instant in whatever you hand me” can call the optic instead of the type. When the invariant itself matters — a balance that must never go negative, a size that must bracket its children — the pure writer makes the transition a value the type system and the tests can see, and a bad write is a failed check rather than a corrupted object.

Reach for the inverse when the lens is speculative generality, or when there is nothing for the decoupling to mediate. The smell that drives Inline is the mirror image: a field whose updates are all one level deep, a lens composed nowhere, a get/set pair whose writer is const-like and whose reader is the identity — and no cross-domain boundaries in sight, so the full structure may pass through every seam and nothing is ever isolated to the target of the projection. The abstraction costs a reader a detour — what is set v₂ (set v₁ s) doing when the program only ever calls set once? — and it costs the compiler nothing it can deduce. When all the code does with the field is read it once, or write it once, a plain field or a pair of functions is clearer.

The move

Fowler’s mechanics are short. Encapsulate Variable: create a function that reads the field, create one that writes it, replace every read with a call to the reader and every write with a call to the writer, and test [2]; the precondition is that nothing reaches the field directly. Remove Setting Method: once the field can be initialised and never needs to be reassigned, delete the setter and initialise at construction [3]. Read together they are the OO route to what the lens packages: reads through a named getter, writes through a named setter, and no bare field = ... anywhere. Stocker’s Scala refactoring catalogue has the same move — turning a mutable field into a pure accessor pair moves the writes, and that is the whole analysis [10].

In the functional reading the move is mechanical. Make the record immutable. Define get as the field accessor and set as a function returning a copy with the field replaced; package them as a lens value. Replace reads with get, writes with set, read-modify-write with modify. Where a field sits inside other records, compose the lenses along the path; where it sits under a branch of a sum, compose a prism first; where the field is one of many, compose a traversal. The OO precondition — no direct access — is replaced by a type: the only way to reach the field is through the optic, and the type checker enforces it. In practice the optic for a field or branch is often auto-derivable from the type — a library macro or generator (eo’s lens/prism, Monocle’s optics, lens’s Template Haskell) writes the “how to reach” for you, and you keep the “what to do” [7], [11].

The functional reading

An optic is a value that knows how to reach a focus inside a source and how to rebuild the source around a new focus. The families differ in how many foci they address: a lens reaches exactly one field of a product, a prism one branch of a sum, a traversal every element of a container. They share one shape — see, modify, rebuild — which is what makes them compose: prism .andThen lens says “that branch, then that field”, and each .andThen prism .andThen lens says “every element, that branch, that field”. The rewrite itself is a pure function on the focus: how you get there is a value, what you do there is a function, and the two are independent. This is the optics view of the same ladder Fowler climbs — the corridor of accessor methods becomes a composable value, and the precondition becomes a type.

The theoretical backbone is the same one that made lenses lawful, not just convenient. Foster, Greenwald, Moore, Pierce and Schmitt defined the well-behaved lens families and proved their combinators — composition, map, recursion — preserve the laws, which is where “lawful by construction” comes from [4]. O’Connor, and then Gibbons and Johnson, gave the categorical reading — lenses are the coalgebras for the store comonad — which is why the different encodings coincide [5], [6]. Van Laarhoven’s representation made the encoding practical as a functor-polymorphic function, which is what Kmett’s lens library builds on [7], [8]; Pickering, Gibbons and Wu’s profunctor optics shows the same idea scales to prisms and traversals [9]. The eo library is our own working treatment: optics derived from the type with one .andThen surface, and a cookbook of runnable recipes organised by the three jobs optics do best — navigating structures, decoupling modules, and threading effects. The three examples below follow its “contingent fields”, “whole trees” and “arbitrary structure” recipes.

To and from

One equation read in two directions. Before, navigation to a field and the edit are repeated together. After, the path is a composed optic and the edit is a separate function. Replace packages the path; inline writes the access directly. before after reach the field match / copy / recurse then change it business rule mixed with navigation path = prism .andThen(lens).each over(path, change) navigation and rule are separate values replace: package the path inline: write the access directly
The koan. One equation, read in two directions: replace (package the reader and pure writer of a field as an optic value — the "how to reach" and the "what to do") to the right, inline (drop the optic, access the field directly) to the left. The move is lawful by construction: the optic families are derived from the types and compose, and the libraries ship solvers that check the ones you write by hand.

The catalogue lists each refactoring in both directions because the two moves are one equation read left to right and right to left. Replace a field with a lens: define get and set for the field, replace every read with get, every write with set or modify, and where the field is nested compose the lenses. Inline a lens: replace get, set and modify at their use sites with direct access, and delete the optic. Both directions are checked by the same property: for all generated inputs, the before-program and the after-program agree on the entry point.

Three examples

Each example is the same program twice, Before and After, in Scala 3 and in Haskell. The optic building blocks — a lens, a prism, a traversal, and the compositions between them — and the hedgehog spec runner live in the entry’s shared/ directory: compiled by run.sh, never shown on the page, so each example is the move itself. The entry point keeps its name and its type, the optic is the only difference, and a hedgehog property verifies that both versions agree on every generated input. The three are eo’s “navigate structures” recipes, in increasing depth of nesting.

1 · A single node: prism and lens composed

A variable’s name in one node of an expression tree. The Before version is a hand-written match that rebuilds the hit branch and lets every other shape pass. The After version is one composed optic — prism .andThen lens: the prism decides whether the value is a Var (the hit), the lens edits the name inside it, and every miss passes through untouched. The how and the what are separate values; the second property checks that the hit is uppercased and every miss passes through.

Before, upperVarName matches EVar and rebuilds its name. After, varP is composed with nameL, then over applies uppercase; misses pass through. before after upperVarName(e) = e match EVar(v) → EVar(v.copy(name = upper)) other → other navigation and edit are one match each use repeats the path varName = varP.andThen(nameL) varP: choose the EVar branch nameL: focus the name field over(varName, upper) misses pass through unchanged compose the path

Before · Scala

// Uppercase the variable of one Var node: a hand-written match
// that rebuilds the hit branch and lets every other shape pass.
object Before:
  case class Var(name: String, ref: Int)
  enum Expr:
    case EVar(v: Var)
    case EApp(f: Expr, x: Expr)
    case ELam(bind: String, body: Expr)

  import Expr.*

  def upperVarName(e: Expr): Expr = e match
    case EVar(v) => EVar(v.copy(name = v.name.toUpperCase))
    case other   => other

Before · Haskell

-- Uppercase the variable of one Var node: a hand-written match
-- that rebuilds the hit branch and lets every other shape pass.
module Before where

import Data.Char (toUpper)

data Var   = Var   { vName :: String, vRef :: Int }
  deriving (Eq, Show)
data Expr  = EVar Var | EApp Expr Expr | ELam String Expr
  deriving (Eq, Show)

upperVarName :: Expr -> Expr
upperVarName (EVar v) = EVar v { vName = map toUpper (vName v) }
upperVarName e        = e

After · Scala

// Same, as one composed optic: the prism matches the Var branch,
// the lens edits its name, every other shape passes through.
object After:
  import Optics.*

  case class Var(name: String, ref: Int)
  enum Expr:
    case EVar(v: Var)
    case EApp(f: Expr, x: Expr)
    case ELam(bind: String, body: Expr)

  import Expr.*

  val varP: Prism[Expr, Var] =
    Prism[Expr, Var](
      { case EVar(v) => Some(v); case _ => None },
      EVar(_),
    )
  val nameL: Lens[Var, String] =
    Lens[Var, String](_.name, (v, n) => v.copy(name = n))
  val varName: PartialLens[Expr, String] = varP.andThen(nameL)

  def upperVarName(e: Expr): Expr = over(varName, _.toUpperCase)(e)

After · Haskell

-- Same, as one composed optic: the prism matches the Var branch,
-- the lens edits its name, every other shape passes through.
module After where

import Data.Char (toUpper)
import Optics (Lens(..), Prism(..), PartialLens(..), andThen, over)

data Var   = Var   { vName :: String, vRef :: Int }
  deriving (Eq, Show)
data Expr  = EVar Var | EApp Expr Expr | ELam String Expr
  deriving (Eq, Show)

varP :: Prism Expr Var
varP = Prism
  { preview = \e -> case e of EVar v -> Just v; _ -> Nothing
  , review  = EVar
  }

nameL :: Lens Var String
nameL = Lens { view = vName, set = \(v, n) -> v { vName = n } }

varName :: PartialLens Expr String
varName = varP `andThen` nameL

upperVarName :: Expr -> Expr
upperVarName = over varName (map toUpper)
The property: Before.upperVarName == After.upperVarName on generated trees, and the hit/miss behaviour

Spec · Scala

//> using scala 3.3.4
//> using dep qa.hedgehog::hedgehog-core:0.14.0
//> using dep qa.hedgehog::hedgehog-runner:0.14.0
import hedgehog.*, hedgehog.core.*, hedgehog.runner.*

object Props extends Properties:
  def tests: List[Test] = List(
    property("upperVarName: Before == After", agrees),
    property("the hit is uppercased, misses pass through", hitAndMiss),
  )

  // A neutral tree, so one generated input feeds both Expr types.
  enum T:
    case TVar(name: String, ref: Int)
    case TApp(f: T, x: T)
    case TLam(bind: String, body: T)

  val genName: Gen[String] =
    Gen.alpha.list(Range.linear(0, 4)).map(_.mkString)
  def genT(depth: Int): Gen[T] =
    val leaf =
      for n <- genName; r <- Gen.int(Range.linear(-10, 10))
      yield T.TVar(n, r)
    if depth == 0 then leaf
    else
      Gen.choice1(
        leaf,
        for
          f <- genT(depth - 1)
          x <- genT(depth - 1)
        yield T.TApp(f, x),
        for b <- genName; body <- genT(depth - 1) yield T.TLam(b, body),
      )

  def toBefore(t: T): Before.Expr = t match
    case T.TVar(n, r)   => Before.Expr.EVar(Before.Var(n, r))
    case T.TApp(f, x)   => Before.Expr.EApp(toBefore(f), toBefore(x))
    case T.TLam(b, e)   => Before.Expr.ELam(b, toBefore(e))
  def fromBefore(e: Before.Expr): T = e match
    case Before.Expr.EVar(v)      => T.TVar(v.name, v.ref)
    case Before.Expr.EApp(f, x)  =>
      T.TApp(fromBefore(f), fromBefore(x))
    case Before.Expr.ELam(b, e)   => T.TLam(b, fromBefore(e))
  def toAfter(t: T): After.Expr = t match
    case T.TVar(n, r)   => After.Expr.EVar(After.Var(n, r))
    case T.TApp(f, x)   => After.Expr.EApp(toAfter(f), toAfter(x))
    case T.TLam(b, e)   => After.Expr.ELam(b, toAfter(e))
  def fromAfter(e: After.Expr): T = e match
    case After.Expr.EVar(v)     => T.TVar(v.name, v.ref)
    case After.Expr.EApp(f, x)  => T.TApp(fromAfter(f), fromAfter(x))
    case After.Expr.ELam(b, e)  => T.TLam(b, fromAfter(e))

  def agrees: Property =
    for t <- genT(4).forAll
    yield
      fromBefore(Before.upperVarName(toBefore(t)))
        ==== fromAfter(After.upperVarName(toAfter(t)))

  def hitAndMiss: Property =
    for
      n <- genName.forAll
      t <- genT(3).forAll
    yield
      val hit = After.upperVarName(After.Expr.EVar(After.Var(n, 0)))
        ==== After.Expr.EVar(After.Var(n.toUpperCase, 0))
      val miss =
        if tNoVars(t) then
          fromAfter(After.upperVarName(toAfter(t))) ==== t
        else Result.success
      hit and miss

  def tNoVars(t: T): Boolean = t match
    case T.TVar(_, _)   => false
    case T.TApp(f, x)   => tNoVars(f) && tNoVars(x)
    case T.TLam(_, b)   => tNoVars(b)
@main def spec(): Unit = SpecRunner.run(Props.tests)

Spec · Haskell

{-# LANGUAGE OverloadedStrings #-}
module Main where

import Control.Monad (unless)
import System.Exit (exitFailure)
import Data.Char (toUpper)
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import qualified Before
import qualified After

-- A neutral tree, so one generated input feeds both Expr types.
data T = TVar String Int | TApp T T | TLam String T
  deriving (Eq, Show)

genName :: Gen String
genName = Gen.string (Range.linear 0 4) Gen.alpha

genT :: Int -> Gen T
genT 0 = TVar <$> genName <*> Gen.int (Range.linear (-10) 10)
genT depth = Gen.choice
  [ TVar <$> genName <*> Gen.int (Range.linear (-10) 10)
  , TApp <$> genT (depth - 1) <*> genT (depth - 1)
  , TLam <$> genName <*> genT (depth - 1)
  ]

toBefore :: T -> Before.Expr
toBefore (TVar n r) = Before.EVar (Before.Var n r)
toBefore (TApp f x) = Before.EApp (toBefore f) (toBefore x)
toBefore (TLam b e) = Before.ELam b (toBefore e)

fromBefore :: Before.Expr -> T
fromBefore (Before.EVar v)  = TVar (Before.vName v) (Before.vRef v)
fromBefore (Before.EApp f x) = TApp (fromBefore f) (fromBefore x)
fromBefore (Before.ELam b e) = TLam b (fromBefore e)

toAfter :: T -> After.Expr
toAfter (TVar n r) = After.EVar (After.Var n r)
toAfter (TApp f x) = After.EApp (toAfter f) (toAfter x)
toAfter (TLam b e) = After.ELam b (toAfter e)

fromAfter :: After.Expr -> T
fromAfter (After.EVar v)   = TVar (After.vName v) (After.vRef v)
fromAfter (After.EApp f x) = TApp (fromAfter f) (fromAfter x)
fromAfter (After.ELam b e) = TLam b (fromAfter e)

prop_agrees :: Property
prop_agrees = property $ do
  t <- forAll (genT 4)
  fromBefore (Before.upperVarName (toBefore t))
    === fromAfter (After.upperVarName (toAfter t))

noVars :: T -> Bool
noVars (TVar _ _) = False
noVars (TApp f x) = noVars f && noVars x
noVars (TLam _ e) = noVars e

prop_hit_and_miss :: Property
prop_hit_and_miss = property $ do
  n <- forAll genName
  t <- forAll (genT 3)
  After.upperVarName (After.EVar (After.Var n 0))
    === After.EVar (After.Var (map toUpper n) 0)
  if noVars t
    then fromAfter (After.upperVarName (toAfter t)) === t
    else success

main :: IO ()
main = do
  ok <- checkParallel $ Group "Props"
    [ ("upperVarName: Before == After", prop_agrees)
    , ("the hit is uppercased, misses pass through", prop_hit_and_miss)
    ]
  unless ok exitFailure

2 · Every node of a tree: the same optic, deeper nesting

Now the same edit is applied at every node of the tree — the nesting is what makes the hand-written version hurt. The Before version is a recursive walk that rebuilds a hit by hand at each level of the recursion; add a level to the tree and the rebuild appears again. The The After version reuses the same varName optic from example 1, and the walk comes from Plated — the recursion of the type as a value, declared once (which fields are the sub-terms) and reused everywhere. The lens says “how to reach a variable name”; the Plated instance says “where the tree recurses”; and the walk applies the optic at every node. This is eo’s “visit across whole trees” recipe — one Plated instance and one everywhere, rather than a hand-written recursive rebuild.

Before, renameAll owns both the recursive walk and the name edit. After, a Plated instance defines which fields recurse, Plated.everywhere supplies the walk, and varName supplies the edit. before after renameAll(e) = e match EVar(v) → edit name EApp(a,b) → recurse a, recurse b ELam(_,b) → recurse b one method owns walk + edit nesting obscures the business rule given Plated[Expr]: descend only Plated.everywhere supplies the walk varName supplies the focus upper supplies the edit three independent, reusable parts the example declares only recursion separate walk and edit

Before · Scala

// Uppercase the variable of every Var node: a recursive walk that
// rebuilds each hit by hand.
object Before:
  case class Var(name: String, ref: Int)
  enum Expr:
    case EVar(v: Var)
    case EApp(f: Expr, x: Expr)
    case ELam(bind: String, body: Expr)

  import Expr.*

  def renameAll(e: Expr): Expr = e match
    case EVar(v)     => EVar(v.copy(name = v.name.toUpperCase))
    case EApp(f, x)  => EApp(renameAll(f), renameAll(x))
    case ELam(b, bd) => ELam(b, renameAll(bd))

Before · Haskell

-- Uppercase the variable of every Var node: a recursive walk that
-- rebuilds each hit by hand.
module Before where

import Data.Char (toUpper)

data Var  = Var  { vName :: String, vRef :: Int }
  deriving (Eq, Show)
data Expr = EVar Var | EApp Expr Expr | ELam String Expr
  deriving (Eq, Show)

renameAll :: Expr -> Expr
renameAll (EVar v)     = EVar v { vName = map toUpper (vName v) }
renameAll (EApp f x)   = EApp (renameAll f) (renameAll x)
renameAll (ELam b bd)  = ELam b (renameAll bd)

After · Scala

// Same, with the single varName optic applied at every node: the walk
// comes from Plated (which fields recurse), not from the example.
object After:
  import Optics.*

  case class Var(name: String, ref: Int)
  enum Expr:
    case EVar(v: Var)
    case EApp(f: Expr, x: Expr)
    case ELam(bind: String, body: Expr)

  import Expr.*

  given Plated[Expr] with
    def descend(f: Expr => Expr)(e: Expr): Expr = e match
      case EApp(a, b)  => EApp(f(a), f(b))
      case ELam(b, bd) => ELam(b, f(bd))
      case e           => e

  val varP: Prism[Expr, Var] =
    Prism[Expr, Var](
      { case EVar(v) => Some(v); case _ => None },
      EVar(_),
    )
  val nameL: Lens[Var, String] =
    Lens[Var, String](_.name, (v, n) => v.copy(name = n))
  val varName: PartialLens[Expr, String] = varP.andThen(nameL)

  def renameAll(e: Expr): Expr =
    Plated.everywhere(over(varName, _.toUpperCase))(e)

After · Haskell

-- Same, with the same varName optic applied at every node: the walk
-- comes from Plated (which fields recurse), not from the example.
module After where

import Data.Char (toUpper)
import Optics
  ( Lens(..), Prism(..), PartialLens(..), Plated(..)
  , andThen, over, everywhere
  )

data Var = Var { vName :: String, vRef :: Int }
  deriving (Eq, Show)
data Expr = EVar Var | EApp Expr Expr | ELam String Expr
  deriving (Eq, Show)

instance Plated Expr where
  descend f (EApp a b)   = EApp (f a) (f b)
  descend f (ELam b bd)  = ELam b (f bd)
  descend _ e            = e

varP :: Prism Expr Var
varP = Prism
  { preview = \e -> case e of EVar v -> Just v; _ -> Nothing
  , review  = EVar
  }

nameL :: Lens Var String
nameL = Lens { view = vName, set = \(v, n) -> v { vName = n } }

varName :: PartialLens Expr String
varName = varP `andThen` nameL

renameAll :: Expr -> Expr
renameAll = everywhere (over varName (map toUpper))
The property: Before.renameAll == After.renameAll on generated trees, and every name is uppercased afterwards

Spec · Scala

//> using scala 3.3.4
//> using dep qa.hedgehog::hedgehog-core:0.14.0
//> using dep qa.hedgehog::hedgehog-runner:0.14.0
import hedgehog.*, hedgehog.core.*, hedgehog.runner.*

object Props extends Properties:
  def tests: List[Test] = List(
    property("renameAll: Before == After", agrees),
    property("every Var name is uppercased after the walk", allUpper),
  )

  // A neutral tree, so one generated input feeds both Expr types.
  enum T:
    case TVar(name: String, ref: Int)
    case TApp(f: T, x: T)
    case TLam(bind: String, body: T)

  val genName: Gen[String] =
    Gen.alpha.list(Range.linear(0, 4)).map(_.mkString)
  def genT(depth: Int): Gen[T] =
    val leaf =
      for n <- genName; r <- Gen.int(Range.linear(-10, 10))
      yield T.TVar(n, r)
    if depth == 0 then leaf
    else
      Gen.choice1(
        leaf,
        for
          f <- genT(depth - 1)
          x <- genT(depth - 1)
        yield T.TApp(f, x),
        for b <- genName; body <- genT(depth - 1) yield T.TLam(b, body),
      )

  def toBefore(t: T): Before.Expr = t match
    case T.TVar(n, r) => Before.Expr.EVar(Before.Var(n, r))
    case T.TApp(f, x) => Before.Expr.EApp(toBefore(f), toBefore(x))
    case T.TLam(b, e) => Before.Expr.ELam(b, toBefore(e))
  def fromBefore(e: Before.Expr): T = e match
    case Before.Expr.EVar(v)     => T.TVar(v.name, v.ref)
    case Before.Expr.EApp(f, x)  => T.TApp(fromBefore(f), fromBefore(x))
    case Before.Expr.ELam(b, e)  => T.TLam(b, fromBefore(e))
  def toAfter(t: T): After.Expr = t match
    case T.TVar(n, r) => After.Expr.EVar(After.Var(n, r))
    case T.TApp(f, x) => After.Expr.EApp(toAfter(f), toAfter(x))
    case T.TLam(b, e) => After.Expr.ELam(b, toAfter(e))
  def fromAfter(e: After.Expr): T = e match
    case After.Expr.EVar(v)     => T.TVar(v.name, v.ref)
    case After.Expr.EApp(f, x)  => T.TApp(fromAfter(f), fromAfter(x))
    case After.Expr.ELam(b, e)  => T.TLam(b, fromAfter(e))

  def agrees: Property =
    for t <- genT(4).forAll
    yield
      fromBefore(Before.renameAll(toBefore(t)))
        ==== fromAfter(After.renameAll(toAfter(t)))

  def allUpper: Property =
    for t <- genT(4).forAll
    yield
      val renamed = fromAfter(After.renameAll(toAfter(t)))
      (allNamesUpper(renamed) && refsUnchanged(renamed, t)) ==== true

  def allNamesUpper(t: T): Boolean = t match
    case T.TVar(n, _)   => n == n.toUpperCase
    case T.TApp(f, x)   => allNamesUpper(f) && allNamesUpper(x)
    case T.TLam(_, b)   => allNamesUpper(b)
  def refsUnchanged(a: T, b: T): Boolean = (a, b) match
    case (T.TVar(_, r1), T.TVar(_, r2)) => r1 == r2
    case (T.TApp(f1, x1), T.TApp(f2, x2)) =>
      refsUnchanged(f1, f2) && refsUnchanged(x1, x2)
    case (T.TLam(_, b1), T.TLam(_, b2)) => refsUnchanged(b1, b2)
    case _ => false
@main def spec(): Unit = SpecRunner.run(Props.tests)

Spec · Haskell

{-# LANGUAGE OverloadedStrings #-}
module Main where

import Control.Monad (unless)
import System.Exit (exitFailure)
import Data.Char (isUpper)
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import qualified Before
import qualified After

-- A neutral tree, so one generated input feeds both Expr types.
data T = TVar String Int | TApp T T | TLam String T
  deriving (Eq, Show)

genName :: Gen String
genName = Gen.string (Range.linear 0 4) Gen.alpha

genT :: Int -> Gen T
genT depth =
  let leaf = TVar <$> genName <*> Gen.int (Range.linear (-10) 10)
  in if depth == 0 then leaf
     else Gen.choice
       [ leaf
       , TApp <$> genT (depth - 1) <*> genT (depth - 1)
       , TLam <$> genName <*> genT (depth - 1)
       ]

toBefore :: T -> Before.Expr
toBefore (TVar n r) = Before.EVar (Before.Var n r)
toBefore (TApp f x) = Before.EApp (toBefore f) (toBefore x)
toBefore (TLam b e) = Before.ELam b (toBefore e)

fromBefore :: Before.Expr -> T
fromBefore (Before.EVar v)  = TVar (Before.vName v) (Before.vRef v)
fromBefore (Before.EApp f x) = TApp (fromBefore f) (fromBefore x)
fromBefore (Before.ELam b e) = TLam b (fromBefore e)

toAfter :: T -> After.Expr
toAfter (TVar n r) = After.EVar (After.Var n r)
toAfter (TApp f x) = After.EApp (toAfter f) (toAfter x)
toAfter (TLam b e) = After.ELam b (toAfter e)

fromAfter :: After.Expr -> T
fromAfter (After.EVar v)  = TVar (After.vName v) (After.vRef v)
fromAfter (After.EApp f x) = TApp (fromAfter f) (fromAfter x)
fromAfter (After.ELam b e) = TLam b (fromAfter e)

prop_agrees :: Property
prop_agrees = property $ do
  t <- forAll (genT 4)
  fromBefore (Before.renameAll (toBefore t))
    === fromAfter (After.renameAll (toAfter t))

allNamesUpper :: T -> Bool
allNamesUpper (TVar n _) = all isUpper n
allNamesUpper (TApp f x) = allNamesUpper f && allNamesUpper x
allNamesUpper (TLam _ b) = allNamesUpper b

refsUnchanged :: T -> T -> Bool
refsUnchanged (TVar _ r1) (TVar _ r2) = r1 == r2
refsUnchanged (TApp f1 x1) (TApp f2 x2) =
  refsUnchanged f1 f2 && refsUnchanged x1 x2
refsUnchanged (TLam _ b1) (TLam _ b2) = refsUnchanged b1 b2
refsUnchanged _ _ = False

prop_all_upper :: Property
prop_all_upper = property $ do
  t <- forAll (genT 4)
  let renamed = fromAfter (After.renameAll (toAfter t))
  allNamesUpper renamed === True
  refsUnchanged renamed t === True

main :: IO ()
main = do
  ok <- checkParallel $ Group "Props"
    [ ("renameAll: Before == After", prop_agrees)
    , ("every Var name is uppercased, refs unchanged", prop_all_upper)
    ]
  unless ok exitFailure

3 · A sparse walk over a list: traversal, prism and lens

A batch of results, some succeeded and some failed; bump only the successes. The Before version is a map carrying the branch test and the rebuild in the same step. The After version is one composed optic — each .andThen prism .andThen lens: the traversal reaches every element, the prism selects the succeeded branch, the lens edits its value, and every failed element passes through untouched. This is eo’s cookbook recipe “visit through arbitrary structure”, which notes that this sparse walk is the shape a hand-rolled loop gets wrong — the container and the branch test fight over who owns the loop; composed optics keep the two apart.

Before, bumpSucceeded combines the list walk, branch selection and value edit in one match. After, each, succeededP and valueL compose, then over adds one. before after xs.map { result → result match Succeeded(ok) → bump ok.value Failed(msg) → keep unchanged walk + branch + field edit are one nested operation succeededP.andThen(valueL).each each: every list element prism: success · lens: value over(eachSucceeded, _ + 1) failed values pass through compose three steps

Before · Scala

// Bump only the successes of a batch: a hand-written map carrying
// the branch test and the rebuild in the same step.
object Before:
  case class Ok(value: Int)
  enum Result:
    case Succeeded(v: Ok)
    case Failed(msg: String)

  import Result.*

  def bumpSucceeded(xs: List[Result]): List[Result] =
    xs.map {
      case Succeeded(ok) => Succeeded(ok.copy(value = ok.value + 1))
      case f @ Failed(_) => f
    }

Before · Haskell

-- Bump only the successes of a batch: a hand-written map carrying
-- the branch test and the rebuild in the same step.
module Before where

data Ok     = Ok     { okValue :: Int }
  deriving (Eq, Show)
data Result = Succeeded Ok | Failed String
  deriving (Eq, Show)

bumpSucceeded :: [Result] -> [Result]
bumpSucceeded = map step
  where
    step (Succeeded ok) = Succeeded ok { okValue = okValue ok + 1 }
    step f@(Failed _)   = f

After · Scala

// Same, with a small traversal composed with a prism and a lens:
// reach every element, match the succeeded branch, edit its value.
object After:
  import Optics.*

  case class Ok(value: Int)
  enum Result:
    case Succeeded(v: Ok)
    case Failed(msg: String)

  import Result.*

  val succeededP: Prism[Result, Ok] =
    Prism[Result, Ok](
      { case Succeeded(v) => Some(v); case _ => None },
      Succeeded(_),
    )
  val valueL: Lens[Ok, Int] =
    Lens[Ok, Int](_.value, (ok, v) => ok.copy(value = v))
  val eachSucceeded: PartialLens[List[Result], Int] =
    succeededP.andThen(valueL).each

  def bumpSucceeded(xs: List[Result]): List[Result] =
    over(eachSucceeded, _ + 1)(xs)

After · Haskell

-- Same, with a small traversal composed with a prism and a lens:
-- reach every element, match the succeeded branch, edit its value.
module After where

import Optics
  ( Lens(..), Prism(..), PartialLens(..)
  , andThen, each, over
  )

data Ok     = Ok     { okValue :: Int }
  deriving (Eq, Show)
data Result = Succeeded Ok | Failed String
  deriving (Eq, Show)

succeededP :: Prism Result Ok
succeededP = Prism
  { preview = \r -> case r of Succeeded ok -> Just ok; _ -> Nothing
  , review  = Succeeded
  }

valueL :: Lens Ok Int
valueL = Lens { view = okValue, set = \(ok, v) -> ok { okValue = v } }

eachSucceeded :: PartialLens [Result] Int
eachSucceeded = each (succeededP `andThen` valueL)

bumpSucceeded :: [Result] -> [Result]
bumpSucceeded = over eachSucceeded (+ 1)
The property: Before.bumpSucceeded == After.bumpSucceeded on generated batches, and only successes are bumped

Spec · Scala

//> using scala 3.3.4
//> using dep qa.hedgehog::hedgehog-core:0.14.0
//> using dep qa.hedgehog::hedgehog-runner:0.14.0
import hedgehog.*, hedgehog.core.*, hedgehog.runner.*

object Props extends Properties:
  def tests: List[Test] = List(
    property("bumpSucceeded: Before == After", agrees),
    property("only successes are bumped, messages pass through",
      onlySucceeded),
  )

  // A neutral list, so one generated input feeds both Result types.
  enum R:
    case Succeeded(value: Int)
    case Failed(msg: String)

  val genValue: Gen[Int] = Gen.int(Range.linear(-100, 100))
  val genMsg: Gen[String] =
    Gen.alpha.list(Range.linear(0, 6)).map(_.mkString)
  def genR: Gen[R] =
    Gen.choice1(
      genValue.map(R.Succeeded(_)),
      genMsg.map(R.Failed(_)),
    )

  def toBefore(r: R): Before.Result = r match
    case R.Succeeded(v) => Before.Result.Succeeded(Before.Ok(v))
    case R.Failed(m)    => Before.Result.Failed(m)
  def fromBefore(r: Before.Result): R = r match
    case Before.Result.Succeeded(ok) => R.Succeeded(ok.value)
    case Before.Result.Failed(m)     => R.Failed(m)
  def toAfter(r: R): After.Result = r match
    case R.Succeeded(v) => After.Result.Succeeded(After.Ok(v))
    case R.Failed(m)    => After.Result.Failed(m)
  def fromAfter(r: After.Result): R = r match
    case After.Result.Succeeded(ok) => R.Succeeded(ok.value)
    case After.Result.Failed(m)     => R.Failed(m)

  def agrees: Property =
    for xs <- genR.list(Range.linear(0, 10)).forAll
    yield
      Before.bumpSucceeded(xs.map(toBefore)).map(fromBefore)
        ==== After.bumpSucceeded(xs.map(toAfter)).map(fromAfter)

  def onlySucceeded: Property =
    for xs <- genR.list(Range.linear(0, 10)).forAll
    yield
      val bumped =
        After.bumpSucceeded(xs.map(toAfter)).map(fromAfter)
      bumped.zip(xs).forall {
        case (R.Succeeded(v1), R.Succeeded(v0)) => v1 == v0 + 1
        case (R.Failed(m1), R.Failed(m0))       => m1 == m0
        case _                                  => false
      } ==== true
@main def spec(): Unit = SpecRunner.run(Props.tests)

Spec · Haskell

{-# LANGUAGE OverloadedStrings #-}
module Main where

import Control.Monad (unless)
import System.Exit (exitFailure)
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import qualified Before
import qualified After

-- A neutral list element, so one generated input feeds both types.
data R = Succeeded Int | Failed String
  deriving (Eq, Show)

genValue :: Gen Int
genValue = Gen.int (Range.linear (-100) 100)

genMsg :: Gen String
genMsg = Gen.string (Range.linear 0 6) Gen.alpha

genR :: Gen R
genR = Gen.choice
  [ Succeeded <$> genValue
  , Failed <$> genMsg
  ]

toBefore :: R -> Before.Result
toBefore (Succeeded v) = Before.Succeeded (Before.Ok v)
toBefore (Failed m)    = Before.Failed m

fromBefore :: Before.Result -> R
fromBefore (Before.Succeeded ok) = Succeeded (Before.okValue ok)
fromBefore (Before.Failed m)     = Failed m

toAfter :: R -> After.Result
toAfter (Succeeded v) = After.Succeeded (After.Ok v)
toAfter (Failed m)    = After.Failed m

fromAfter :: After.Result -> R
fromAfter (After.Succeeded ok) = Succeeded (After.okValue ok)
fromAfter (After.Failed m)     = Failed m

prop_agrees :: Property
prop_agrees = property $ do
  xs <- forAll (Gen.list (Range.linear 0 10) genR)
  map fromBefore (Before.bumpSucceeded (map toBefore xs))
    === map fromAfter (After.bumpSucceeded (map toAfter xs))

prop_only_succeeded :: Property
prop_only_succeeded = property $ do
  xs <- forAll (Gen.list (Range.linear 0 10) genR)
  let bumped = map fromAfter (After.bumpSucceeded (map toAfter xs))
  and (zipWith check bumped xs) === True
  where
    check (Succeeded v1) (Succeeded v0) = v1 == v0 + 1
    check (Failed m1)    (Failed m0)    = m1 == m0
    check _ _                           = False

main :: IO ()
main = do
  ok <- checkParallel $ Group "Props"
    [ ("bumpSucceeded: Before == After", prop_agrees)
    , ("only successes bumped, messages pass through",
      prop_only_succeeded)
    ]
  unless ok exitFailure

Pitfalls

The real pitfalls of this move are the ones that come from carrying machinery that does not earn its keep, or from rebuilding more than the focus.

  • Accidental complexity. An optic is worth its indirection when the path composes or varies; a lens for a leaf field that only one caller reads and one writes is a detour — a plain field says the same thing. The same for a traversal where a plain map with an explicit match would do: if the container and the branch never change, the two-in-one loop is clearer. This is the inverse side of the equation; it is also the most common way the move goes wrong.
  • Rebuilding more than the focus. When you write an optic by hand, the writer must rebuild only what was matched or selected. A prism’s review that reconstructs a value with the wrong fields, or a set that touches a neighbouring field, silently changes the program — the composition is only as good as the instances you compose, which is why the law-solvers in Verification matter.
  • Laziness and evaluation count. modify reads the focus, applies a function and writes it back. In Haskell the setter is lazy in the new value, and rewriting one element of a traversal must not force the others; in Scala a def recomputes and a val shares, so where a lens is stored and how it is applied changes how often its functions run.
  • Choosing the wrong family. A lens addresses exactly one field, a prism one branch, a traversal many. “Focus a pair of fields” or “edit a field that may be absent” are different families (an affine traversal, a prism), and composing optics whose foci do not line up is a type error, not a runtime bug — the types reject the onesided composition, so the mistake shows up at compile time rather than in production.

In each case the fix is the same: choose the smallest optic that says the path, derive it from the type when you can, and check the ones you write by hand.

Verification

Because the move is an equation, its correctness is a property: for all inputs x in the domain of the entry point, Before x == After x. That is a one-line property in the sense Claessen and Hughes introduced with QuickCheck, where a generator produces inputs and the framework searches for a counterexample and shrinks it to a minimal one [13]. The catalogue states every entry this way, in Scala and in Haskell, with hedgehog on both sides [14]. Hedgehog is used because its shrinking is integrated into the generator, so a shrunk counterexample obeys the same invariants as a generated one and the minimal failing input it reports is a real input of the program, not an artefact of a separate shrinker.

A property is only worth having if it can fail, so each spec above was mutation-checked: change After so it is no longer equivalent — a sign flip, a wrong target — confirm the property reports and shrinks a counterexample, then restore After. A property that does not fail under mutation is testing the generator, not the refactoring.

The optics themselves need no per-example law properties, because they are lawful by construction — but the libraries ship law-solvers for the instances you do write by hand, so you do not have to re-derive the rules. eo ships cats-eo-laws with FooTests/FooLaws for every optic family, Monocle ships monocle-law with LensLaws and PrismLaws [11], and for Haskell genvalidity-hspec-optics provides lensSpec and prismSpec one-liners [12]. The property checks the refactoring; the solvers check the optics you wrote to do it.

To run everything on this page yourself, from a checkout of the site repository:

sh pages/refactorings/replace-mutable-fields-with-lenses/run.sh

It needs scala-cli and either GHC with hedgehog installed or Docker, and ends with all properties passed.

References

  1. Martin Fowler. Refactoring: Improving the Design of Existing Code. Addison-Wesley, 1999. https://martinfowler.com/books/refactoring.html
  2. Martin Fowler. Encapsulate Variable (formerly Encapsulate Field, before that Self-Encapsulate Field). Refactoring.com, online edition. https://refactoring.com/catalog/encapsulateField.html
  3. Martin Fowler. Remove Setting Method. Refactoring.com, online edition. https://refactoring.com/catalog/removeSettingMethod.html
  4. J. Nathan Foster, Michael B. Greenwald, Jonathan T. Moore, Benjamin C. Pierce and Alan Schmitt. “Combinators for Bidirectional Tree Transformations: A Linguistic Approach to the View-Update Problem”. ACM Transactions on Programming Languages and Systems 29(3):17, 2007. https://doi.org/10.1145/1232420.1232424
  5. Russell O’Connor. “Functor is to Lens as Applicative is to Biplate: Introducing Multiplate”. In Proceedings of the ACM SIGPLAN Workshop on Generic Programming (WGP 2011), pp. 25–36. https://arxiv.org/abs/1103.2841
  6. Jeremy Gibbons and Michael Johnson. “Relating Algebraic and Coalgebraic Descriptions of Lenses”. Electronic Communications of the EASST 49:1–16, 2012 (Workshop on Bidirectional Transformations 2012). https://doi.org/10.14279/tuj.eceasst.49.726
  7. Twan van Laarhoven. “Talk on Lenses”. Slides, Radboud University Nijmegen, 17 May 2011. https://www.twanvl.nl/blog/news/2011-05-19-lenses-talk
  8. Edward Kmett. lens: Lenses, Folds and Traversals, and Control.Lens documentation. https://hackage.haskell.org/package/lens
  9. Matthew Pickering, Jeremy Gibbons and Nicolas Wu. “Profunctor Optics: Modular Data Accessors”. The Art, Science, and Engineering of Programming 1(2):7, 2017. https://doi.org/10.22152/programming-journal.org/2017/1/7
  10. Mirko Stocker. Scala Refactoring. Master’s thesis, HSR Hochschule für Technik Rapperswil, 2010. https://eprints.ost.ch/id/eprint/286/
  11. Julien Truffaut and contributors. Monocle: Optics Library for Scala (including monocle-law). https://www.optics.dev/Monocle/
  12. Constructive Programming. eo: optics library and cookbook for Scala 3. https://eo.constructive.dev (cookbook)
  13. Koen Claessen and John Hughes. “QuickCheck: a lightweight tool for random testing of Haskell programs”. In Proceedings of the ACM SIGPLAN International Conference on Functional Programming (ICFP 2000), pp. 268–279. https://doi.org/10.1145/351240.351266
  14. Jacob Stanley and contributors. Hedgehog: release with confidence, state-of-the-art property testing. https://github.com/hedgehogqa/haskell-hedgehog and https://github.com/hedgehogqa/scala-hedgehog