Funx.Optics.Prism (funx v0.8.0)

View Source

Run in Livebook

The Funx.Optics.Prism module provides a lawful partial optic for focusing on a branch of a data structure.

A prism is partial: the focus may or may not be present. This makes prisms ideal for working with optional values, variants, and sum types. Unlike lenses, prisms never raise—they return Maybe instead.

When to use prisms vs lenses:

  • Prisms (partial): Use for optional values, variants, sum types, missing map keys.
  • Lenses (total): Use for record fields, map keys that always exist.

Constructors

  • key/1: Focuses on an optional key in a map.
  • struct/1: Focuses on a specific struct type (for sum types).
  • path/1: Focuses on nested paths through maps and structs.
  • make/2: Creates a custom prism from preview and review functions.

Core Operations

  • preview/2: Attempts to extract the focus, returning Just(value) or Nothing.
  • review/2: Reconstructs the whole structure from the focused value.

Important: review constructs a fresh structure from the focused value alone—it does not merge or preserve other fields. This is lawful prism behavior. If you need to update while preserving other fields, use a lens instead.

Composition

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

Prisms compose naturally. Composing two prisms yields a new prism that attempts both matches in sequence, stopping at the first Nothing.

Monoid Structure

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

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

  • Identity: make(fn x -> Maybe.from_nil(x) end, fn x -> x end) - the identity prism
  • Operation: compose/2 - sequential composition

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

Examples

Working with optional map keys:

iex> name_prism = Funx.Optics.Prism.key(:name)
iex> Funx.Optics.Prism.preview(%{name: "Alice"}, name_prism)
%Funx.Monad.Maybe.Just{value: "Alice"}
iex> Funx.Optics.Prism.preview(%{age: 30}, name_prism)
%Funx.Monad.Maybe.Nothing{}

Composing prisms for nested access:

iex> outer = Funx.Optics.Prism.key(:person)
iex> inner = Funx.Optics.Prism.key(:name)
iex> composed = Funx.Optics.Prism.compose(outer, inner)
iex> Funx.Optics.Prism.preview(%{person: %{name: "Alice"}}, composed)
%Funx.Monad.Maybe.Just{value: "Alice"}
iex> Funx.Optics.Prism.preview(%{person: %{age: 30}}, composed)
%Funx.Monad.Maybe.Nothing{}

Using path/1 for convenient nested access:

iex> person_name = Funx.Optics.Prism.path([:person, :name])
iex> Funx.Optics.Prism.preview(%{person: %{name: "Alice"}}, person_name)
%Funx.Monad.Maybe.Just{value: "Alice"}

Summary

Functions

Composes prisms into a single prism using sequential composition.

Builds a prism that focuses on a single key inside a map.

Creates a custom prism from previewer and reviewer functions.

Builds a prism that focuses on a nested path through maps and structs.

Attempts to extract the focus from a structure using the prism.

Reconstructs the whole structure from the focused part.

Builds a prism that focuses on a specific struct constructor.

Types

previewer(s, a)

@type previewer(s, a) :: (s -> Funx.Monad.Maybe.t(a))

reviewer(s, a)

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

t()

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

t(s, a)

@type t(s, a) :: %Funx.Optics.Prism{preview: previewer(s, a), review: reviewer(s, a)}

Functions

compose(prisms)

@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 prisms into a single prism using sequential composition.

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

Binary composition

Composes two prisms. The outer prism runs first; if it succeeds, the inner prism runs next.

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 preview: Applies outer's matcher first, then inner's matcher (short-circuits on Nothing)
  • On review: Applies inner's builder first, then outer's builder

This is sequential matching through nested structures.

iex> outer = Funx.Optics.Prism.key(:account)
iex> inner = Funx.Optics.Prism.key(:name)
iex> p = Funx.Optics.Prism.compose(outer, inner)
iex> Funx.Optics.Prism.preview(%{account: %{name: "Alice"}}, p)
%Funx.Monad.Maybe.Just{value: "Alice"}

List composition

Composes a list of prisms into a single prism using sequential composition.

Sequential semantics:

  • On preview: Applies matchers in list order (left-to-right), stopping at first Nothing
  • On review: Applies builders in reverse list order (right-to-left)

This is not a union or choice operator. It does not "try all branches." It is strict sequential matching and construction.

iex> prisms = [
...>   Funx.Optics.Prism.key(:account),
...>   Funx.Optics.Prism.key(:name)
...> ]
iex> p = Funx.Optics.Prism.compose(prisms)
iex> Funx.Optics.Prism.preview(%{account: %{name: "Alice"}}, p)
%Funx.Monad.Maybe.Just{value: "Alice"}
iex> Funx.Optics.Prism.preview(%{other: %{name: "Bob"}}, p)
%Funx.Monad.Maybe.Nothing{}

key(k)

@spec key(atom()) :: t(map(), any())

Builds a prism that focuses on a single key inside a map.

Examples

iex> p = Funx.Optics.Prism.key(:name)
iex> Funx.Optics.Prism.preview(%{name: "Alice"}, p)
%Funx.Monad.Maybe.Just{value: "Alice"}
iex> Funx.Optics.Prism.preview(%{age: 30}, p)
%Funx.Monad.Maybe.Nothing{}

make(preview, review)

@spec make(previewer(s, a), reviewer(s, a)) :: t(s, a) when s: term(), a: term()

Creates a custom prism from previewer and reviewer functions.

The previewer attempts to extract the focused part, returning a Maybe. The reviewer reconstructs the whole structure from the focused part.

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

Examples

iex> p =
...>   Funx.Optics.Prism.make(
...>     fn x -> Funx.Monad.Maybe.just(x) end,
...>     fn x -> x end
...>   )
iex> Funx.Optics.Prism.preview(5, p)
%Funx.Monad.Maybe.Just{value: 5}

path(path)

@spec path([atom() | {module(), atom()}]) :: t(map(), any())

Builds a prism that focuses on a nested path through maps and structs.

Each element in the path can be:

  • :atom - A plain key access (works with maps and structs)
  • Module - A naked struct verification (checks type, no key access)
  • {Module, :atom} - A struct-typed key access (verifies struct type and accesses key)

The syntax expands as follows:

  • :keykey(:key) - plain key access
  • Modulestruct(Module) - struct type verification
  • {Module, :key}compose(struct(Module), key(:key)) - typed field access

Modules are distinguished from plain keys using function_exported?(atom, :__struct__, 0).

Examples

# Plain map path
p1 = Prism.path([:person, :bio, :age])
Prism.review(30, p1)
#=> %{person: %{bio: %{age: 30}}}

# Given struct modules:
defmodule Bio do
  defstruct [:age, :location]
end

defmodule Person do
  defstruct [:name, :bio]
end

# Struct-typed path using {Module, :key} syntax
p2 = Prism.path([{Person, :bio}, {Bio, :age}])
Prism.review(30, p2)
#=> %Person{bio: %Bio{age: 30, location: nil}, name: nil}

# Naked struct at end verifies final type
p3 = Prism.path([:profile, Bio])
Prism.preview(%{profile: %Bio{age: 30}}, p3)
#=> Just(%Bio{age: 30, location: nil})

# Naked struct at beginning verifies root type
p4 = Prism.path([Person, :name])
Prism.review("Alice", p4)
#=> %Person{name: "Alice", bio: nil}

# Mix naked structs with typed field syntax
p5 = Prism.path([{Person, :bio}, Bio, :age])
Prism.review(25, p5)
#=> %Person{bio: %Bio{age: 25, location: nil}, name: nil}

# Naked struct only (just type verification)
p6 = Prism.path([Person])
Prism.preview(%Person{name: "Bob"}, p6)
#=> Just(%Person{name: "Bob", bio: nil})

Implementation

The path/1 function composes prisms using compose/1:

  • :key[key(:key)]
  • Module[struct(Module)]
  • {Mod, :key}[struct(Mod), key(:key)]

This means path is just syntactic sugar for prism composition.

Important

  • When using {Module, :field}, ensure :field exists in Module
  • Using non-existent fields may violate prism laws (Kernel.struct/2 silently drops invalid keys)
  • The tuple form {Module, :key} requires Module to be a struct module (raises otherwise)
  • Plain lowercase atoms like :user are always treated as keys, not struct modules

preview(s, prism)

@spec preview(s, t(s, a)) :: Funx.Monad.Maybe.t(a) when s: term(), a: term()

Attempts to extract the focus from a structure using the prism.

Returns a Funx.Monad.Maybe.Just on success or Funx.Monad.Maybe.Nothing if the branch does not match.

Examples

iex> p = Funx.Optics.Prism.key(:name)
iex> Funx.Optics.Prism.preview(%{name: "Alice"}, p)
%Funx.Monad.Maybe.Just{value: "Alice"}
iex> Funx.Optics.Prism.preview(%{age: 30}, p)
%Funx.Monad.Maybe.Nothing{}

review(a, prism)

@spec review(a, t(s, a)) :: s when s: term(), a: term()

Reconstructs the whole structure from the focused part.

Review reverses the prism, injecting the focused value back into the outer structure. Important: review constructs a fresh structure from the focused value alone - it does not merge with or patch an existing structure. This is the lawful behaviour of prisms.

If you need to update a field while preserving other fields, you need a lens, not a prism.

Note: Cannot review with nil as it would violate prism laws (since Just(nil) is invalid).

Examples

iex> p = Funx.Optics.Prism.key(:name)
iex> Funx.Optics.Prism.review("Alice", p)
%{name: "Alice"}

struct(mod)

@spec struct(module()) :: t(struct(), struct())

Builds a prism that focuses on a specific struct constructor.

This prism succeeds only when the input value is a struct of the given module. It models a sum-type constructor: selecting one structural variant from a set of possible variants.

On review, this prism can promote a plain map to the specified struct type, filling in defaults for missing fields.

Examples

# Given a struct module:
defmodule Account do
  defstruct [:name, :email]
end

# Create a prism for that struct type
p = Prism.struct(Account)

# Preview succeeds for matching struct
Prism.preview(%Account{name: "Alice"}, p)
#=> %Just{value: %Account{name: "Alice", email: nil}}

# Preview fails for non-matching types
Prism.preview(%{name: "Bob"}, p)
#=> %Nothing{}

# Review promotes a map to the struct type
Prism.review(%{name: "Charlie"}, p)
#=> %Account{name: "Charlie", email: nil}

Composition

The struct/1 prism is commonly composed with key/1 to focus on struct fields:

user_name = Prism.compose(Prism.struct(Account), Prism.key(:name))
Prism.review("Alice", user_name)
#=> %Account{name: "Alice", email: nil}