Funx.Optics.Lens (funx v0.8.0)

View Source

Run in Livebook

The Funx.Optics.Lens module provides a lawful total optic for focusing on a part of a data structure.

A lens is total: it assumes the focus always exists within the valid domain. This is a contract enforced at runtime by raising KeyError when violated. If the focus might not exist, use a prism instead.

Both view! and set! enforce totality symmetrically for all data types (maps, structs, etc.). If either operation can succeed when the focus is missing, you no longer have a lens.

Constructors

  • key/1: Focuses on a single key in a map or struct.
  • path/1: Focuses on nested keys by composing key/1 lenses.
  • make/2: Creates a custom lens from viewer and updater functions.

Core Operations

Safe Operations

Error handling modes:

Safe operations accept an optional :as parameter:

  • :either (default): Returns Right(value) or Left(exception).
  • :tuple: Returns {:ok, value} or {:error, exception}.
  • :raise: Behaves like the ! version, raising exceptions directly.

Safe operations use Either.from_try/1 internally, which catches all exceptions, not just KeyError.

Composition

  • compose/2: Composes two lenses sequentially (outer then inner).
  • compose/1: Composes a list of lenses into a single lens.

Lenses compose naturally. Composing two lenses yields a new lens that focuses through both layers sequentially.

Monoid Structure

Lenses form a monoid under composition for a fixed outer type s.

The monoid structure is provided via Funx.Monoid.Optics.LensCompose, which wraps lenses for use with generic monoid operations:

  • Identity: make(fn s -> s end, fn _s, a -> a end) - the identity lens
  • Operation: compose/2 - sequential composition

You can use compose/1 to compose multiple lenses sequentially, or work directly with Funx.Monoid.Optics.LensCompose for more control.

Examples

iex> alias Funx.Optics.Lens
iex> lens = Lens.key(:age)
iex> %{age: 40} |> Lens.view!(lens)
40
iex> %{age: 40} |> Lens.set!(lens, 50)
%{age: 50}

Composing lenses for nested access:

iex> alias Funx.Optics.Lens
iex> outer = Lens.key(:profile)
iex> inner = Lens.key(:score)
iex> lens = Lens.compose(outer, inner)
iex> %{profile: %{score: 12}} |> Lens.view!(lens)
12
iex> %{profile: %{score: 12}} |> Lens.set!(lens, 99)
%{profile: %{score: 99}}

Deeply nested composition with compose/1:

iex> alias Funx.Optics.Lens
iex> lens = Lens.compose([Lens.key(:stats), Lens.key(:wins)])
iex> %{stats: %{wins: 7}} |> Lens.view!(lens)
7
iex> %{stats: %{wins: 7}} |> Lens.set!(lens, 8)
%{stats: %{wins: 8}}

Summary

Functions

Composes lenses into a single lens using sequential composition.

Builds a lawful lens focusing on a single key in a map or struct.

Creates a custom lens from viewer and updater functions.

Safe version of over!/3 that returns an Either or tuple instead of raising.

Updates the focused part of a structure by applying a function to it.

Builds a lawful lens for nested map access by composing key/1 lenses.

Safe version of set!/3 that returns an Either or tuple instead of raising.

Updates the focused part of a structure by setting it to a new value.

Safe version of view!/2 that returns an Either or tuple instead of raising.

Extracts the focused part of a structure using a lens.

Types

t()

@type t() :: t(any(), any())

t(s, a)

@type t(s, a) :: %Funx.Optics.Lens{update: updater(s, a), view: viewer(s, a)}

updater(s, a)

@type updater(s, a) :: (s, a -> s)

viewer(s, a)

@type viewer(s, a) :: (s -> a)

Functions

compose(lenses)

@spec compose([t()]) :: t()

compose(outer, inner)

@spec compose(t(s, i), t(i, a)) :: t(s, a) when s: term(), i: term(), a: term()

Composes lenses into a single lens using sequential composition.

This delegates to the monoid append operation, which contains the canonical composition logic.

Binary composition

Composes two lenses. The outer lens focuses first, then the inner lens focuses within the result.

This is left-to-right composition: the first parameter is applied first. This differs from mathematical function composition (f ∘ g applies g first).

Sequential semantics:

  • On view!: Applies outer's viewer first, then inner's viewer
  • On set!: Updates through both lenses, maintaining nested structure

This is sequential focusing through nested structures.

iex> alias Funx.Optics.Lens
iex> outer = Lens.key(:profile)
iex> inner = Lens.key(:age)
iex> lens = Lens.compose(outer, inner)
iex> %{profile: %{age: 30}} |> Lens.view!(lens)
30

List composition

Composes a list of lenses into a single lens using sequential composition.

Sequential semantics:

  • On view!: Applies viewers in list order (left-to-right)
  • On set!: Updates through each lens in reverse order to maintain structure

This is sequential focusing through nested structures.

iex> lenses = [
...>   Funx.Optics.Lens.key(:user),
...>   Funx.Optics.Lens.key(:profile),
...>   Funx.Optics.Lens.key(:age)
...> ]
iex> lens = Funx.Optics.Lens.compose(lenses)
iex> %{user: %{profile: %{age: 25}}} |> Funx.Optics.Lens.view!(lens)
25

key(k)

@spec key(term()) :: t(map(), term())

Builds a lawful lens focusing on a single key in a map or struct.

Contract

The key must exist in the structure. When used with view! and set!, this lens uses Map.fetch!/2 and Map.replace!/3, raising KeyError if the key is missing. This symmetric enforcement ensures all three lens laws hold.

If the key might not exist, use a prism instead.

Type Note

The return type t(map(), term()) uses Elixir's map() type, which includes both plain maps and structs (since structs are maps with a __struct__ key).

Examples

iex> lens = Funx.Optics.Lens.key(:name)
iex> %{name: "Alice"} |> Funx.Optics.Lens.view!(lens)
"Alice"
iex> %{name: "Alice"} |> Funx.Optics.Lens.set!(lens, "Bob")
%{name: "Bob"}

Works with string keys:

iex> lens = Funx.Optics.Lens.key("count")
iex> %{"count" => 5} |> Funx.Optics.Lens.view!(lens)
5

With structs (preserves type):

defmodule User, do: defstruct [:name, :age]
lens = Funx.Optics.Lens.key(:name)
user = %User{name: "Alice", age: 30}
Funx.Optics.Lens.view!(user, lens) #=> "Alice"
Funx.Optics.Lens.set!(user, lens, "Bob") #=> %User{name: "Bob", age: 30}

make(viewer, updater)

@spec make(viewer(s, a), updater(s, a)) :: t(s, a) when s: term(), a: term()

Creates a custom lens from viewer and updater functions.

The viewer extracts the focused part from the structure. The updater takes the structure and a new value, returning an updated structure.

Both functions must maintain the lens laws for the result to be lawful.

Examples

iex> # A lens that views and updates the length of a string
iex> lens = Funx.Optics.Lens.make(
...>   fn s -> String.length(s) end,
...>   fn s, len -> String.duplicate(s, div(len, String.length(s))) end
...> )
iex> Funx.Optics.Lens.view!("hello", lens)
5

over(s, lens, f, opts \\ [])

@spec over(s, t(s, a), (a -> a), keyword()) ::
  Funx.Monad.Either.t(any(), s) | {:ok, s} | {:error, any()} | s
when s: term(), a: term()

Safe version of over!/3 that returns an Either or tuple instead of raising.

See the "Safe Operations" section in the module documentation for details about error handling modes and what exceptions are caught.

Examples

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.over(%{age: 30}, lens, fn a -> a + 1 end)
%Funx.Monad.Either.Right{right: %{age: 31}}

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.over(%{}, lens, fn a -> a + 1 end)
%Funx.Monad.Either.Left{left: %KeyError{key: :age, term: %{}}}

iex> lens = Funx.Optics.Lens.key(:score)
iex> Funx.Optics.Lens.over(%{score: 10}, lens, fn s -> s * 2 end, as: :tuple)
{:ok, %{score: 20}}

iex> lens = Funx.Optics.Lens.key(:value)
iex> Funx.Optics.Lens.over(%{value: 5}, lens, fn v -> v + 1 end, as: :raise)
%{value: 6}

over!(s, lens, f)

@spec over!(s, t(s, a), (a -> a)) :: s when s: term(), a: term()

Updates the focused part of a structure by applying a function to it.

This is the derived transformation operation for a lens. It is implemented as:

  • view!/2 to extract the focused part
  • Application of the given function
  • set!/3 to write the result back

Because lenses are total, over!/3 is also total. If the focus does not exist, a KeyError is raised by view!/2 or set!/3.

Only the focused part is changed. All other structure and data is preserved.

Examples

iex> lens = Funx.Optics.Lens.key(:age)
iex> data = %{age: 40}
iex> Funx.Optics.Lens.over!(data, lens, fn a -> a + 1 end)
%{age: 41}

Works through composed lenses:

iex> outer = Funx.Optics.Lens.key(:profile)
iex> inner = Funx.Optics.Lens.key(:score)
iex> lens = Funx.Optics.Lens.compose(outer, inner)
iex> data = %{profile: %{score: 10}}
iex> Funx.Optics.Lens.over!(data, lens, fn s -> s * 2 end)
%{profile: %{score: 20}}

Works through path/1:

iex> lens = Funx.Optics.Lens.path([:stats, :wins])
iex> data = %{stats: %{wins: 3}}
iex> Funx.Optics.Lens.over!(data, lens, fn n -> n + 5 end)
%{stats: %{wins: 8}}

path(keys)

@spec path([term()]) :: t(map(), term())

Builds a lawful lens for nested map access by composing key/1 lenses.

This is equivalent to compose(Enum.map(keys, &key/1)) and enforces totality at every level - raising KeyError when used with view! or set! if any intermediate key is missing.

Type Note

The return type t(map(), term()) uses Elixir's map() type, which includes both plain maps and structs (since structs are maps with a __struct__ key).

Examples

iex> lens = Funx.Optics.Lens.path([:user, :profile, :name])
iex> data = %{user: %{profile: %{name: "Alice"}}}
iex> Funx.Optics.Lens.view!(data, lens)
"Alice"
iex> Funx.Optics.Lens.set!(data, lens, "Bob")
%{user: %{profile: %{name: "Bob"}}}

Raises on missing keys when accessed:

iex> lens = Funx.Optics.Lens.path([:user, :name])
iex> Funx.Optics.Lens.view!(%{}, lens)
** (KeyError) key :user not found in: %{}

set(s, lens, a, opts \\ [])

@spec set(s, t(s, a), a, keyword()) ::
  Funx.Monad.Either.t(any(), s) | {:ok, s} | {:error, any()} | s
when s: term(), a: term()

Safe version of set!/3 that returns an Either or tuple instead of raising.

See the "Safe Operations" section in the module documentation for details about error handling modes and what exceptions are caught.

Examples

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.set(%{age: 30}, lens, 31)
%Funx.Monad.Either.Right{right: %{age: 31}}

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.set(%{}, lens, 31)
%Funx.Monad.Either.Left{left: %KeyError{key: :age, term: %{}}}

iex> lens = Funx.Optics.Lens.key(:count)
iex> Funx.Optics.Lens.set(%{count: 5}, lens, 10, as: :tuple)
{:ok, %{count: 10}}

iex> lens = Funx.Optics.Lens.key(:name)
iex> Funx.Optics.Lens.set(%{name: "Alice"}, lens, "Bob", as: :raise)
%{name: "Bob"}

set!(s, lens, a)

@spec set!(s, t(s, a), a) :: s when s: term(), a: term()

Updates the focused part of a structure by setting it to a new value.

Raises KeyError if the focus does not exist. The entire structure is returned with only the focused part changed. All other fields and nested structures are preserved. Struct types are maintained.

For non-raising behavior, use set/4 instead.

Examples

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.set!(%{age: 30, name: "Alice"}, lens, 31)
%{age: 31, name: "Alice"}

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.set!(%{name: "Alice"}, lens, 31)
** (KeyError) key :age not found in: %{name: "Alice"}

view(s, lens, opts \\ [])

@spec view(s, t(s, a), keyword()) ::
  Funx.Monad.Either.t(any(), a) | {:ok, a} | {:error, any()} | a
when s: term(), a: term()

Safe version of view!/2 that returns an Either or tuple instead of raising.

See the "Safe Operations" section in the module documentation for details about error handling modes and what exceptions are caught.

Examples

iex> lens = Funx.Optics.Lens.key(:name)
iex> Funx.Optics.Lens.view(%{name: "Alice"}, lens)
%Funx.Monad.Either.Right{right: "Alice"}

iex> lens = Funx.Optics.Lens.key(:name)
iex> Funx.Optics.Lens.view(%{}, lens)
%Funx.Monad.Either.Left{left: %KeyError{key: :name, term: %{}}}

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.view(%{age: 30}, lens, as: :tuple)
{:ok, 30}

iex> lens = Funx.Optics.Lens.key(:age)
iex> Funx.Optics.Lens.view(%{age: 30}, lens, as: :raise)
30

view!(s, lens)

@spec view!(s, t(s, a)) :: a when s: term(), a: term()

Extracts the focused part of a structure using a lens.

Raises KeyError if the focus does not exist (e.g., missing map key). For non-raising behavior, use view/3 instead.

Examples

iex> lens = Funx.Optics.Lens.key(:name)
iex> Funx.Optics.Lens.view!(%{name: "Alice"}, lens)
"Alice"

iex> lens = Funx.Optics.Lens.key(:name)
iex> Funx.Optics.Lens.view!(%{}, lens)
** (KeyError) key :name not found in: %{}