Funx.Optics.Iso (funx v0.8.0)

View Source

Run in Livebook

The Funx.Optics.Iso module provides a lawful isomorphism optic for bidirectional, lossless transformations.

An isomorphism (iso) represents a reversible transformation between two types. It consists of two inverse functions that satisfy the round-trip laws:

  • review(view(s, iso), iso) == s - Round-trip forward then back returns the original
  • view(review(a, iso), iso) == a - Round-trip back then forward returns the original

Isos are total optics with no partiality. If the transformation can fail, you do not have an iso. Contract violations crash immediately - there are no bang variants or safe alternatives.

Constructors

  • make/2: Creates a custom iso from two inverse functions.
  • identity/0: The identity iso (both directions are identity).

Core Operations

  • view/2: Apply the forward transformation (s -> a).
  • review/2: Apply the backward transformation (a -> s).
  • over/3: Modify the viewed side (view, apply function, review).
  • under/3: Modify the reviewed side (review, apply function, view).

Direction

  • from/1: Reverse the iso's direction.

Composition

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

Interoperability

An iso is more powerful than both lens and prism. Every iso can be used as a lens (viewing and setting always succeed) or as a prism (preview always returns Just).

Isos compose naturally. Composing two isos yields a new iso where:

  • Forward (view) applies the outer iso first, then the inner iso
  • Backward (review) applies the inner iso first, then the outer iso

Monoid Structure

Isos form a monoid under composition.

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

Composing an empty list returns the identity iso.

Examples

Simple encoding/decoding:

iex> alias Funx.Optics.Iso
iex> # Iso between string and integer (string representation)
iex> string_int = Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> Iso.view("42", string_int)
42
iex> Iso.review(42, string_int)
"42"

Composing isos:

iex> alias Funx.Optics.Iso
iex> # Iso: string <-> integer
iex> string_int = Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> # Iso: integer <-> doubled integer
iex> double = Iso.make(
...>   fn i -> i * 2 end,
...>   fn i -> div(i, 2) end
...> )
iex> # Composed: string <-> doubled integer
iex> composed = Iso.compose(string_int, double)
iex> Iso.view("21", composed)
42
iex> Iso.review(42, composed)
"21"

Using over and under:

iex> alias Funx.Optics.Iso
iex> string_int = Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> Iso.over("10", string_int, fn i -> i * 5 end)
"50"
iex> Iso.under(100, string_int, fn s -> s <> "0" end)
1000

Summary

Functions

Converts an iso to a lens.

Converts an iso to a prism.

Composes isos into a single iso using sequential composition.

Reverses the direction of an iso.

The identity iso that leaves values unchanged in both directions.

Creates a custom iso from two inverse functions.

Modify the viewed side of the iso.

Apply the backward transformation of the iso.

Modify the reviewed side of the iso.

Apply the forward transformation of the iso.

Types

backward(a, s)

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

forward(s, a)

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

t()

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

t(s, a)

@type t(s, a) :: %Funx.Optics.Iso{review: backward(a, s), view: forward(s, a)}

Functions

as_lens(iso)

@spec as_lens(t(s, a)) :: Funx.Optics.Lens.t(s, a) when s: term(), a: term()

Converts an iso to a lens.

An iso is more powerful than a lens: it provides bidirectional transformation, while a lens only provides viewing and updating. Every iso can be used as a lens.

The resulting lens:

  • view uses the iso's forward transformation
  • update ignores the old value and uses the iso's backward transformation

This is safe because an iso is total - the transformation always succeeds.

Examples

iex> alias Funx.Optics.{Iso, Lens}
iex> string_int = Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> lens = Iso.as_lens(string_int)
iex> Lens.view!("42", lens)
42
iex> Lens.set!("10", lens, 99)
"99"

as_prism(iso)

@spec as_prism(t(s, a)) :: Funx.Optics.Prism.t(s, a) when s: term(), a: term()

Converts an iso to a prism.

An iso is more powerful than a prism: it never fails to extract a value, while a prism models optional extraction. Every iso can be used as a prism.

The resulting prism:

  • preview always succeeds (returns Just), using the iso's forward transformation
  • review uses the iso's backward transformation

This is safe because an iso is total - the transformation always succeeds.

Examples

iex> alias Funx.Optics.{Iso, Prism}
iex> alias Funx.Monad.Maybe.Just
iex> string_int = Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> prism = Iso.as_prism(string_int)
iex> Prism.preview("42", prism)
%Just{value: 42}
iex> Prism.review(42, prism)
"42"

compose(isos)

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

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

Binary composition

Composes two isos. The outer iso transforms first, then the inner iso transforms 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 forward transformation first, then inner's forward transformation
  • On review: Applies inner's backward transformation first, then outer's backward transformation

This is sequential transformation through composed isos.

iex> alias Funx.Optics.Iso
iex> # string <-> int
iex> string_int = Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> # int <-> doubled int
iex> double = Iso.make(
...>   fn i -> i * 2 end,
...>   fn i -> div(i, 2) end
...> )
iex> composed = Iso.compose(string_int, double)
iex> Iso.view("21", composed)
42
iex> Iso.review(42, composed)
"21"

List composition

Composes a list of isos into a single iso using sequential composition.

Sequential semantics:

  • On view: Applies transformations in list order (left-to-right)
  • On review: Applies transformations in reverse list order (right-to-left)

This is sequential transformation through composed isos.

iex> isos = [
...>   Funx.Optics.Iso.make(
...>     fn s -> String.to_integer(s) end,
...>     fn i -> Integer.to_string(i) end
...>   ),
...>   Funx.Optics.Iso.make(
...>     fn i -> i * 2 end,
...>     fn i -> div(i, 2) end
...>   )
...> ]
iex> composed = Funx.Optics.Iso.compose(isos)
iex> Funx.Optics.Iso.view("21", composed)
42

from(iso)

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

Reverses the direction of an iso.

Swaps the view and review functions.

This is the established optic operation for reversing direction, following Haskell's Control.Lens.Iso.from.

Examples

iex> string_int = Funx.Optics.Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> int_string = Funx.Optics.Iso.from(string_int)
iex> Funx.Optics.Iso.view(42, int_string)
"42"
iex> Funx.Optics.Iso.review("42", int_string)
42

identity()

@spec identity() :: t()

The identity iso that leaves values unchanged in both directions.

Examples

iex> iso = Funx.Optics.Iso.identity()
iex> Funx.Optics.Iso.view(42, iso)
42
iex> Funx.Optics.Iso.review(42, iso)
42

make(viewer, reviewer)

@spec make(forward(s, a), backward(a, s)) :: t(s, a) when s: term(), a: term()

Creates a custom iso from two inverse functions.

The viewer function transforms from the source type to the target type. The reviewer function transforms from the target type back to the source type.

Both functions must be inverses for the iso to be lawful:

  • review(view(s, iso), iso) == s
  • view(review(a, iso), iso) == a

If these functions are not true inverses, the iso contract is violated and the program is incorrect. There are no runtime checks - the contract is enforced by design.

Examples

iex> # Celsius <-> Fahrenheit
iex> temp_iso = Funx.Optics.Iso.make(
...>   fn c -> c * 9 / 5 + 32 end,
...>   fn f -> (f - 32) * 5 / 9 end
...> )
iex> Funx.Optics.Iso.view(0, temp_iso)
32.0
iex> Funx.Optics.Iso.review(32, temp_iso)
0.0

over(s, iso, f)

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

Modify the viewed side of the iso.

Applies a function through the iso: view, apply function, review.

This is the standard optic modifier, consistent with Lens and Prism.

Examples

iex> string_int = Funx.Optics.Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> Funx.Optics.Iso.over("10", string_int, fn i -> i * 5 end)
"50"

review(a, iso)

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

Apply the backward transformation of the iso.

Transforms from the target type back to the source type.

This operation is total. If it crashes, the iso contract is violated.

Examples

iex> string_int = Funx.Optics.Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> Funx.Optics.Iso.review(42, string_int)
"42"

under(a, iso, f)

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

Modify the reviewed side of the iso.

Applies a function in reverse through the iso: review, apply function, view.

This operation is unique to Iso due to its bidirectional symmetry. Lens and Prism cannot offer this.

Examples

iex> string_int = Funx.Optics.Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> Funx.Optics.Iso.under(100, string_int, fn s -> s <> "0" end)
1000

view(s, iso)

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

Apply the forward transformation of the iso.

Transforms from the source type to the target type.

This operation is total. If it crashes, the iso contract is violated.

Examples

iex> string_int = Funx.Optics.Iso.make(
...>   fn s -> String.to_integer(s) end,
...>   fn i -> Integer.to_string(i) end
...> )
iex> Funx.Optics.Iso.view("42", string_int)
42