Powered by AppSignal & Oban Pro

Funx.Optics.Iso

livebooks/optics/iso.livemd

Funx.Optics.Iso

Mix.install([
  {:funx, "0.4.2"}
])

Overview

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.

Quick Reference

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.
  • compose/1: Composes a list of isos into a single iso.

Function Examples

alias Funx.Optics.Iso

Building Isos

make/2

Create isos from pairs of inverse functions. The most common use is for encoding/decoding and unit conversions.

# String <-> Integer
string_int_iso =
  Iso.make(
    fn s -> String.to_integer(s) end,
    fn i -> Integer.to_string(i) end
  )
# Celsius <-> Fahrenheit
# Note: In production, use exact representations (integer base units or Decimal)
# to maintain true losslessness. Floats accumulate rounding errors.
celsius_fahrenheit_iso =
  Iso.make(
    fn c -> c * 9 / 5 + 32 end,
    fn f -> (f - 32) * 5 / 9 end
  )
# Miles <-> Kilometers
miles_km_iso =
  Iso.make(
    fn miles -> miles * 1.60934 end,
    fn km -> km / 1.60934 end
  )

identity/0

The identity iso applies no transformation in either direction.

id_iso = Iso.identity()

Core Operations

view/2

Apply the forward transformation (s -> a).

Iso.view("42", string_int_iso)
Iso.view(0, celsius_fahrenheit_iso)
Iso.view(100, miles_km_iso)

review/2

Apply the backward transformation (a -> s).

Iso.review(42, string_int_iso)
Iso.review(32, celsius_fahrenheit_iso)
Iso.review(160.934, miles_km_iso)

Round-trip properties

An iso always round-trips. The transformations are exact inverses (within the precision of your numeric representation).

# Forward then back
original = "100"
original |> Iso.view(string_int_iso) |> then(&amp;Iso.review(&amp;1, string_int_iso))
# Back then forward
original = 100
original |> Iso.review(string_int_iso) |> then(&amp;Iso.view(&amp;1, string_int_iso))

over/3

Modify the viewed side: view → apply function → review.

This lets you work in the “viewed” representation while keeping your data in the “source” representation.

# Start with string "10", modify as integer, get back string
Iso.over("10", string_int_iso, fn i -> i * 5 end)
# Start with Celsius, modify as Fahrenheit, get back Celsius
Iso.over(0, celsius_fahrenheit_iso, fn f -> f + 10 end)

under/3

Modify the reviewed side: review → apply function → view.

This lets you work in the “source” representation while keeping your data in the “viewed” representation.

# Start with integer 100, modify as string, get back integer
Iso.under(100, string_int_iso, fn s -> s <> "0" end)
# Start with Fahrenheit, modify as Celsius, get back Fahrenheit
Iso.under(32, celsius_fahrenheit_iso, fn c -> c + 10 end)

Direction

from/1

Reverse the iso’s direction. This swaps view and review.

# Original: string -> int
Iso.view("42", string_int_iso)
# Reversed: int -> string (same as review)
reversed_iso = Iso.from(string_int_iso)
Iso.view(42, reversed_iso)
# Reversed: string -> int (same as original view)
Iso.review("42", reversed_iso)

Composition

compose/2

Compose two isos sequentially. The resulting iso applies transformations in sequence.

# Iso: int -> doubled int
double_iso =
  Iso.make(
    fn i -> i * 2 end,
    fn i -> div(i, 2) end
  )
# Compose: string -> int -> doubled int
string_to_doubled =
  Iso.compose(string_int_iso, double_iso)
# "21" -> 21 -> 42
Iso.view("21", string_to_doubled)
# 42 -> 21 -> "21"
Iso.review(42, string_to_doubled)

compose/1

Compose a list of isos into a single iso.

# Build incrementally
add_one =
  Iso.make(
    fn n -> n + 1 end,
    fn n -> n - 1 end
  )

add_two = Iso.compose(add_one, add_one)
add_five = Iso.compose([add_two, add_two, add_one])
Iso.view(10, add_five)
Iso.review(15, add_five)

Composing an empty list returns the identity iso.

empty_compose = Iso.compose([])
Iso.view(42, empty_compose)

Working with Structs

Isos work naturally with structs for converting between different representations of the same data.

defmodule Celsius do
  defstruct [:value]
end

defmodule Fahrenheit do
  defstruct [:value]
end
temp_struct_iso =
  Iso.make(
    fn %Celsius{value: c} ->
      %Fahrenheit{value: c * 9 / 5 + 32}
    end,
    fn %Fahrenheit{value: f} ->
      %Celsius{value: (f - 32) * 5 / 9}
    end
  )
c_temp = %Celsius{value: 0}
f_temp = Iso.view(c_temp, temp_struct_iso)
Iso.review(f_temp, temp_struct_iso)

Practical Example: JSON Encoding

A common use of isos is encoding/decoding between internal representations and external formats.

defmodule User do
  defstruct [:id, :name, :email]
end
user_json_iso =
  Iso.make(
    # User -> Map (for JSON encoding)
    fn %User{id: id, name: name, email: email} ->
      %{"id" => id, "name" => name, "email" => email}
    end,
    # Map (from JSON decoding) -> User
    fn %{"id" => id, "name" => name, "email" => email} ->
      %User{id: id, name: name, email: email}
    end
  )
user = %User{id: 1, name: "Alice", email: "alice@example.com"}
# Convert to map for JSON encoding
json_map = Iso.view(user, user_json_iso)
# Convert back from map (after JSON decoding)
Iso.review(json_map, user_json_iso)

When to Use Isos

Use isos when you have:

  • Lossless transformations: Converting between equivalent representations
  • Unit conversions: Temperature, distance, currency (with exact numeric types)
  • Encoding/decoding: Serialization, format conversions
  • Type conversions: Between isomorphic data structures

Don’t use isos when:

  • The transformation is not reversible (use a function or lens instead)
  • The transformation loses information (use a prism for partial transformations)
  • The transformation can fail (use a prism with Maybe)
  • You need to handle missing data (use a lens with safe variants)

Why No Bang Variants?

Notice that Iso operations have no ! suffix and no safe alternatives.

This is intentional. An Iso models no failure.

The bang convention in Elixir means “this has a non-bang counterpart that models failure.” But isos are total - if the transformation can fail, you don’t have an iso.

If an iso operation crashes, that’s a contract violation (a broken iso), not an expected failure mode. The crash is loud, immediate, and correct.