“I didn’t say it would be easy. I just said it would be the truth.” —Morpheus, The Matrix (1999)

I wanted to cover optics in my book, but length requirements meant I needed to stop at monads.

I finally have time to start adding them to Funx.

Run in Livebook

Why Lenses?

If you read technical papers on lensing, they explain why it is “interesting”, but they do not explain why we might want to adopt it.

There are plenty of blog posts that try to make that case.

They Solve a Tedium Problem

A common argument is that manipulating nested data is difficult.

This is certainly true in Scala. But as one Elixir developer observes, it is not a meaningful challenge in Elixir.

And even Haskell developers debate whether the tradeoffs are worth it.

Lenses are Composable

Another common argument is that lenses compose well.

But projection functions compose just as well, making it hard to say that composition alone can justify bringing lenses into a codebase.

Helpful for Hard Problems

Lenses can help in some difficult cases.

Which suggests lensing is useful when a problem is complicated enough to justify the abstraction.

These are all incidental benefits. They are not the core reason to adopt lenses.

The purpose of lensing is not convenience. It is legality.

Elixir’s Built-in Accessors

Elixir provides get_in/2, put_in/3, and update_in/3 which operate on paths that describe how to navigate into a structure.

First, we’ll start with some data:

garfield = %{
  name: "Garfield",
  weight: 20,
  owner: %{
    name: "Jon"
  }
}

Here is the path into the owner’s name:

valid_owner_name_path = [:owner, :name]

We can read through the path:

get_in(garfield, valid_owner_name_path)
# "Jon"

We can write through the path:

put_in(garfield, valid_owner_name_path, "Jonathon")
# %{name: "Garfield", owner: %{name: "Jonathon"}, weight: 20}

And we can update through the path:

update_in(garfield, valid_owner_name_path, fn name -> String.upcase(name) end)
# %{name: "Garfield", owner: %{name: "JON"}, weight: 20}

Now let’s consider an invalid path:

invalid_owner_age_path = [:owner, :age]
#[:owner, :age]

If we read:

get_in(garfield, invalid_owner_age_path)
# nil

We get an ambiguous nil. It could mean the :owner key is missing or the :age key is missing. It could mean both keys exist and the value is nil.

When we write:

put_in(garfield, invalid_owner_age_path, 40)
# %{name: "Garfield", owner: %{name: "Jon", age: 40}, weight: 20}

This succeeds. Even though the path does not actually point to a valid location in the original structure, Elixir treats the write as a create and silently changes the shape of the data.

And when we update:

update_in(garfield, invalid_owner_age_path, fn curr -> curr + 1 end)
# ** (ArithmeticError) bad argument in arithmetic expression: nil + 1

Here the fallback breaks down completely. The curr is nil and we crash with an ArithmeticError.

At this point the same path has produced three different behaviors depending on which operation we chose: read returns nil, write creates new structure, update crashes.

This is convenient, but is not a lawful lens.

Lawful Lenses: Symmetric, Explicit Contracts

A lens is a contract that guarantees symmetric behavior: if you can read through it, you can write through it, and vice versa. All operations make the same assumptions about the focus. No fallbacks, no auto-creation, no surprises.

Funx provides lawful lenses.

alias Funx.Optics.Lens

Let’s use path/1 to lift our valid path:

valid_owner_name_lens = Lens.path(valid_owner_name_path)

With Lens, we view to read:

Lens.view!(garfield, valid_owner_name_lens)
# "Jon"

Set to write:

Lens.set!(garfield, valid_owner_name_lens, "Jonathon")
# %{name: "Garfield", weight: 20, owner: %{name: "Jonathon"}}

And over to update:

Lens.over!(garfield, valid_owner_name_lens, fn name -> String.upcase(name) end)
# %{name: "Garfield", weight: 20, owner: %{name: "JON"}}

Elixir doesn’t have a type system to prove whether a lens is valid at compile time, so every operation has two possible outcomes: success or explicit failure.

Let’s look at an invalid path:

invalid_owner_age_lens = Lens.path(invalid_owner_age_path)

Like Elixir’s update_in/3 accessor, our update raises an error:

Lens.over!(garfield, invalid_owner_age_lens, fn curr -> curr + 1 end)
# ** (KeyError) key :age not found in: %{name: "Jon"}

But instead of an ArithmeticError, we get a KeyError. The lens won’t apply a function to an invalid focus.

Let’s write to an invalid focus:

Lens.set!(garfield, invalid_owner_age_lens, "Jonathon")
# ** (KeyError) key :age not found in: %{name: "Jon"}

Again, a KeyError. A lawful lens will not write to an invalid focus.

And if we view:

Lens.view!(garfield, invalid_owner_age_lens)
# ** (KeyError) key :age not found in: %{name: "Jon"}

You guessed it, a KeyError. A lawful lens will not even allow us to read an invalid focus.

A Lens is Lawful

If the focus is valid, you can read it, write it, and update it. If not valid, all operations fail explicitly. There is no fallback, no auto-creation, and no silent fixing.

This is what makes composition safe and refactoring predictable. Behavior depends only on the lens itself, not the runtime shape of the data. Everything makes the same assumptions, and errors stop exactly where an assumption breaks instead of being “cured” into a hard-to-find downstream bug.

Structs have a fixed schema. The asymmetric behavior of put_in (creating keys on write) is fundamentally incompatible with that constraint. Lawful lenses enforce symmetric contracts, which means they will work on structs.

Yes, We Can Lens a Struct

defmodule Owner do
  defstruct [:name]
end

defmodule Cat do
  defstruct [:name, :owner, :weight]
end
granny = %Owner{name: "Granny"}
sylvester = %Cat{name: "Sylvester", owner: granny, weight: 15}
# %Cat{name: "Sylvester", owner: %Owner{name: "Granny"}, weight: 15}

Elixir’s accessor cannot lens a struct, even with a valid focus:

get_in(sylvester, valid_owner_name_path)
# ** (UndefinedFunctionError) function Cat.fetch/2 is undefined 
# (Cat does not implement the Access behaviour)

But Funx can:

Lens.view!(sylvester, valid_owner_name_lens)
# "Granny"
Lens.set!(sylvester, valid_owner_name_lens, "Gramps")
# %Cat{name: "Sylvester", weight: 15, owner: %Owner{name: "Gramps"}}
Lens.over!(sylvester, valid_owner_name_lens, fn name -> String.upcase(name) end)
# %Cat{name: "Sylvester", weight: 15, owner: %Owner{name: "GRANNY"}}

In a lawful Lens, an invalid focus will always fail:

Lens.view!(sylvester, invalid_owner_age_lens)
# ** (KeyError) key :age not found in: %Owner{name: "Granny"}
Lens.set!(sylvester, invalid_owner_age_lens, "Jonathon")
# ** (KeyError) key :age not found in: %Owner{name: "Granny"}
Lens.over!(sylvester, invalid_owner_age_lens, fn name -> String.upcase(name) end)
# ** (KeyError) key :age not found in: %Owner{name: "Granny"}

But we don’t always have to raise an error. We can use the safe view/2, set/3, and over/3:

Lens.over(sylvester, invalid_owner_age_lens, fn name -> String.upcase(name) end)
# %Funx.Monad.Either{value: %KeyError{key: :age, term: %Owner{name: "Granny"}}}

Here, Funx’s default behavior is its Either.

But we can elect Elixir’s tuple:

Lens.view(sylvester, invalid_owner_age_lens, as: :tuple)
# {:error, %KeyError{key: :age, term: %Owner{name: "Granny"}}}

Where we get the typical {:ok, value} or {:error, reason} tuple.

So, Why Lenses?

It is not about composition or avoiding tediousness.

We want lenses to solve this problem:

put_in(garfield, [:owner, :nane], "Dave")
# %{name: "Garfield", owner: %{name: "Jon", nane: "Dave"}, weight: 20}

There’s a downstream bug.

struct(sylvester, %{oner: %Owner{name: "Dave"}})
# %Cat{name: "Sylvester", owner: %Owner{name: "Granny"}, weight: 15}

There’s a different bug.

Lens.set!(garfield, Lens.path([:owner, :nane]), "Dave")
# (KeyError) key :nane not found in: %{name: "Jon"}

Under the Lens contract, our code was wrong, so it raised the error.

More Complex Examples

Let’s make a Superhero domain.

defmodule Powers do
  defstruct [:strength, :speed, :intelligence]

  def total(%Powers{} = p), do: p.strength + p.speed + p.intelligence
end

defmodule Hero do
  defstruct [:name, :alias, :powers]
end

defmodule Headquarters do
  @cities %{
    "New York" => {40.7128, -74.0060},
    "Los Angeles" => {34.0522, -118.2437},
    "San Francisco" => {37.7749, -122.4194}
  }

  defstruct [:city, :latitude, :longitude]

  def relocate(city) when is_map_key(@cities, city) do
    {lat, lon} = Map.fetch!(@cities, city)
    %Headquarters{city: city, latitude: lat, longitude: lon}
  end
end

defmodule Team do
  defstruct [:name, :leader, :headquarters, :founded]
end
iron_man = %Hero{
  name: "Tony Stark",
  alias: "Iron Man",
  powers: %Powers{
    strength: 85,
    speed: 70,
    intelligence: 100
  }
}

avengers = %Team{
  name: "Avengers",
  leader: %Hero{
    name: "Steve Rogers",
    alias: "Captain America",
    powers: %Powers{
      strength: 90,
      speed: 75,
      intelligence: 80
    }
  },
  headquarters: %Headquarters{
    city: "New York",
    latitude: 40.7128,
    longitude: -74.0060
  },
  founded: 1963
}

Constructors

key/1 - single field focus

alias_lens = Lens.key(:alias)
Lens.view!(iron_man, alias_lens)
# "Iron Man"

path/1 - nested field access

leader_name_lens = Lens.path([:leader, :name])
Lens.view!(avengers, leader_name_lens)
# "Steve Rogers"

Composition

Lenses compose to reach arbitrary depth:

leader_lens = Lens.key(:leader)
powers_lens = Lens.key(:powers)
intelligence_lens = Lens.key(:intelligence)

leader_intelligence =
  leader_lens
  |> Lens.compose(powers_lens)
  |> Lens.compose(intelligence_lens)

Lens.view!(avengers, leader_intelligence)
# 80
avengers
|> Lens.set!(leader_intelligence, 195)
|> Lens.view!(Lens.compose(leader_lens, powers_lens))
# %Powers{strength: 90, speed: 75, intelligence: 195}

We can also use a compose/1 with a list:

leader_intelligence_lens = Lens.compose([
  Lens.key(:leader),
  Lens.key(:powers),
  Lens.key(:intelligence)
])

Lens.view!(avengers, leader_intelligence_lens)
# 80

Or use path/1 as shorthand, which leverages compose behind the scenes:

Lens.view!(avengers, Lens.path([:leader, :powers, :intelligence]))
# 80

Composition scales to arbitrary depth while preserving lawfulness. Whether you use compose/2, compose/1, or path/1, every lens maintains the same contract: symmetric, total, and type-preserving.

And honestly, if you know this much, you know enough. Feel free to stop here.

The Blue Pill: Advanced Lenses

So far we’ve used the key/1 and path/1 constructors. Behind the scenes, both are thin wrappers over make/2 that generate simple structural lenses.

But not every update is a simple structural write.

What happens when a single logical change must update multiple fields together?

Let’s start by defining some basic lenses:

headquarters_lens = Lens.key(:headquarters)
city_lens = Lens.compose(headquarters_lens, Lens.key(:city))

With this lens, we can change the city:

avengers
|> Lens.set!(city_lens, "Los Angeles")
|> Lens.view!(headquarters_lens)
# %Headquarters{city: "Los Angeles", latitude: 40.7128, longitude: -74.0060}

Structurally, this works, but it is semantically wrong. The city is updated, while the latitude and longitude still point to New York. We have violated one of our domain invariants with a lawful structural operation.

We need a lens which focuses the city, but atomically sets the entire headquarters.

relocate_lens =
Lens.make(
  fn team ->
    Lens.view!(team, city_lens)
  end,
  fn team, new_city ->
    Lens.set!(team, headquarters_lens, Headquarters.relocate(new_city))
  end
)

make/2 is the core of a lens. It binds two directions:

  • how the focus is read
  • how the structure is rebuilt when the focus changes

Both sides must agree on the same logical value. Here, that value is the city.

With this, relocation is a single lawful operation:

avengers
|> Lens.set!(relocate_lens, "Los Angeles")
# %Team{
#      name: "Avengers",
#      leader: %Hero{...},
#      headquarters: %Headquarters{city: "Los Angeles", latitude: 34.0522, longitude: -118.2437},
#      founded: 1963
#    }

The headquarters, including city, latitude, and longitude, is updated as one atomic rewrite.

Because this is still a lens, it composes like any other:

avengers
|> Lens.set!(relocate_lens, "San Francisco")
|> Lens.set!(Lens.key(:name), "West Coast Avengers")
# %Team{
#      name: "West Coast Avengers",
#      leader: %Hero{...},
#      headquarters: %Headquarters{city: "San Francisco", latitude: 37.7749, longitude: -122.4194},
#      founded: 1963
#    }

And we can even use Either’s DSL to create a safe pipe:

use Funx.Monad.Either

either avengers, as: :tuple do
  bind Lens.set(relocate_lens, "San Francisco")
  bind Lens.set(Lens.key(:name), "West Coast Avengers")
  bind Lens.set(leader_intelligence, 195)
  bind Lens.set(Lens.path([:leader, :alias]), "Cap")
end
# {:ok,
#  %Team{
#    name: "West Coast Avengers",
#    leader: %Hero{
#      name: "Steve Rogers",
#      alias: "Cap",
#      powers: %Powers{strength: 90, speed: 75, intelligence: 195}
#    },
#    headquarters: %Headquarters{city: "San Francisco", latitude: 37.7749, longitude: -122.4194},
#    founded: 1963
# }}

Which will report its first error:

either avengers, as: :tuple do
  bind Lens.set(relocate_lens, "San Francisco")
  bind Lens.set(Lens.key(:nane), "West Coast Avengers")
  bind Lens.set(leader_intelligence, 195)
  bind Lens.set(Lens.path([:leader, :alias]), "Cap")
end
# {:error, %KeyError{key: :nane, term: %Team{...}}}

Derived Values as Fields

Not every field we care about is explicitly stored. Some values are derived from multiple fields but still behave like a single domain concept.

Here, a hero’s effective power is derived from three fields: strength, speed, and intelligence. We want to observe that value as a single focus before we decide how it should be rewritten.

strength_lens = Lens.key(:strength)
speed_lens = Lens.key(:speed)
intelligence_lens = Lens.key(:intelligence)

power_level = fn powers ->
  Lens.view!(powers, strength_lens) +
  Lens.view!(powers, speed_lens) +
  Lens.view!(powers, intelligence_lens)
end

This function is read-only. It defines how the derived value is computed from the underlying structure.

Next, we define the inverse operation: how a new derived total is redistributed back by proportionally scaling each field.

scale_powers = fn powers, new_total ->
  old_total = power_level.(powers)
  ratio = new_total / old_total

  strength = round(powers.strength * ratio)
  speed = round(powers.speed * ratio)

  intelligence =
    new_total - strength - speed

  %Powers{}
  |> Lens.set!(Lens.key(:strength), strength)
  |> Lens.set!(Lens.key(:speed), speed)
  |> Lens.set!(Lens.key(:intelligence), intelligence)
end

Now we bind those two directions together using make/2.

power_level_lens =
  Lens.make(
    fn hero ->
      hero
      |> Lens.view!(powers_lens)
      |> power_level.()
    end,
    fn hero, new_level ->
      current_powers = Lens.view!(hero, powers_lens)
      Lens.set!(
        hero,
        powers_lens,
        scale_powers.(current_powers, new_level)
      )
    end
  )

This lens now behaves like a field:

  • the viewer derives a value
  • the setter performs a coordinated multi-field rewrite
  • both sides agree on the same logical focus

We can read it:

Lens.view!(iron_man, power_level_lens)
# 255

And we can update it:

iron_man
|> Lens.set!(power_level_lens, 50)
|> Lens.view!(power_level_lens)
# 50

From the call site, there is no distinction between this derived value and a stored field. The difference is not in how it is used. The difference is that the lens enforces a coordinated rewrite instead of a simple assignment.

And don’t forget, if we mess up and try to set the power level for our Garfield map:

garfield
|> Lens.set!(power_level_lens, 50)
# ** (KeyError) key :powers not found in: %{name: "Garfield", weight: 20, owner: %{name: "Jon"}}

We get that KeyError.

Conclusion

Elixir makes nested updates convenient. Funx makes them correct. The difference is lawfulness: symmetric contracts that catch bugs at the call site instead of downstream, work uniformly on maps and structs, and compose without surprises. As your codebase grows, that predictability compounds.

Resources

Advanced Functional Programming with Elixir

Dive deeper into functional programming patterns and advanced Elixir techniques. Learn how to build robust, maintainable applications using functional programming principles.

Funx - Functional Programming for Elixir

A library of functional programming abstractions for Elixir, including monads, monoids, Eq, Ord, and more. Built as an ecosystem where learning is the priority from the start.