Funx: The Optic Iso
“I see dead people.” — The Sixth Sense (1999)
What’s an Iso?
An iso represents a reversible change of representation. Nothing is added, nothing is lost.
Let’s start simple. Here is an iso that adds one.
alias Funx.Optics.{Iso, Lens, Prism}
add_one_iso =
Iso.make(
fn n -> n + 1 end,
fn n -> n - 1 end
)
view/2 applies the forward transformation.
Iso.view(42, add_one_iso)
# 43
review/2 applies the inverse transformation.
Iso.review(43, add_one_iso)
# 42
An iso always round-trips. The inverse is exact, not a fallback.
Composing Isos
Isomorphisms are composable:
add_two_iso = Iso.compose(add_one_iso, add_one_iso)
Iso.view(42, add_two_iso)
# 44
And we still have reversibility:
Iso.review(44, add_two_iso)
# 42
Because the inverse remains valid, composition can scale arbitrarily.
add_five_iso = Iso.compose([add_two_iso, add_two_iso, add_one_iso])
add_ten_iso = Iso.compose([add_five_iso, add_five_iso])
add_fifty_iso = Iso.compose([add_ten_iso, add_ten_iso, add_ten_iso, add_ten_iso, add_ten_iso])
add_one_hundred_three_iso = Iso.compose([add_fifty_iso, add_fifty_iso, add_two_iso, add_one_iso])
updated_value = Iso.view(42, add_one_hundred_three_iso)
# 145
And the original value can always be recovered.
Iso.review(updated_value, add_one_hundred_three_iso)
# 42
Within the context of add_one_hundred_three_iso, 145 and 42 are different representations of the same thing.
Unit conversion
In 1999, NASA lost the Mars Climate Orbiter because one team’s software output thrust data in pound-force seconds while another team’s navigation software expected newton-seconds, and the orbiter approached Mars at the wrong altitude and burned up in the atmosphere.
That wasn’t a calculation error. It was a representation error. Two teams looking at the same data, but seeing different things.
That’s a job for Iso.
Pound-force seconds and newton-seconds are two representations of impulse (thrust over time). Let’s start with an Iso to switch between them:
lbf_seconds_to_newton_seconds_iso =
Iso.make(
fn lbf_s -> lbf_s * 4.44822 end,
fn n_s -> n_s / 4.44822 end
)
Here, we have the basic conversion logic, but it is not quite an Iso:
newtons = Iso.view(120, lbf_seconds_to_newton_seconds_iso)
# 533.7864
Notice what happens when we review:
Iso.review(newtons, lbf_seconds_to_newton_seconds_iso)
# 119.99999999999999
Here we have a bit of Float rounding. Without reversibility, we lose information as we pass it back and forth.
We could, for the sake of this demonstration, pretend that is an Iso, but it’s not. Instead we need a different strategy, such as using Rational numbers:
defmodule Rational do
defstruct [:num, :den]
def from_integer(n), do: %Rational{num: n, den: 1}
def add(%Rational{num: n1, den: d1}, %Rational{num: n2, den: d2}) do
normalize(%Rational{
num: n1 * d2 + n2 * d1,
den: d1 * d2
})
end
def subtract(%Rational{num: n1, den: d1}, %Rational{num: n2, den: d2}) do
normalize(%Rational{
num: n1 * d2 - n2 * d1,
den: d1 * d2
})
end
def multiply(%Rational{num: n1, den: d1}, %Rational{num: n2, den: d2}) do
normalize(%Rational{num: n1 * n2, den: d1 * d2})
end
def divide(%Rational{num: n1, den: d1}, %Rational{num: n2, den: d2}) do
normalize(%Rational{num: n1 * d2, den: d1 * n2})
end
def normalize(%Rational{num: n, den: d}) do
g = Integer.gcd(n, d)
%Rational{
num: div(n, g),
den: div(d, g)
}
end
def to_float(%Rational{num: n, den: d}) do
n / d
end
end
Here is that conversion with Rational:
factor =
%Rational{
num: 4_448_221_615_260_5,
den: 1_000_000_000_000_0
}
# %Rational{num: 44482216152605, den: 10000000000000}
And we can use that for our Iso:
lbf_seconds_to_newton_seconds_iso =
Iso.make(
fn lbf -> Rational.multiply(lbf, factor) end,
fn n -> Rational.divide(n, factor) end
)
Next, we need a couple of structs to represent our data:
defmodule LbfSeconds do
defstruct [:value]
end
defmodule NewtonSeconds do
defstruct [:value]
end
And a Lens to focus on the structs’ value:
value_lens = Lens.key(:value)
Next, let’s leverage lbf_seconds_to_newton_seconds for an Iso to manage the relationship between our structs:
impulse_iso =
Iso.make(
fn %LbfSeconds{value: lbf} ->
%NewtonSeconds{
value: Iso.view(lbf, lbf_seconds_to_newton_seconds_iso)
}
end,
fn %NewtonSeconds{value: ns} ->
%LbfSeconds{
value: Iso.review(ns, lbf_seconds_to_newton_seconds_iso)
}
end
)
Starting with 100 pound-force seconds of impulse:
lbf = %LbfSeconds{value: Rational.from_integer(100)}
# %LbfSeconds{value: %Rational{num: 100, den: 1}}
The Iso.view/2 will convert that to newtons.
newton = Iso.view(lbf, impulse_iso)
# %NewtonSeconds{value: %Rational{num: 8896443230521, den: 20000000000}}
And we can review/2 to return to the initial value:
Iso.review(newton, impulse_iso)
# %LbfSeconds{value: %Rational{num: 100, den: 1}}
If we need to know the float value, we can convert NewtonSeconds to a float:
newton
|> Lens.view!(value_lens)
|> Rational.to_float()
# 444.82216152605
100 LbfSeconds are about equal to 444.822 Newton Seconds.
When dealing with conversions, always perform the float conversion at the edge, where we don’t have the expectation of reversibility.
Update an Iso
Let’s add 10 units to our values.
First, a function that adds 10:
add_ten =
fn n ->
Rational.add(n, Rational.from_integer(10))
end
With this, we can use Lens.over/3 to update the internal value:
Lens.over!(lbf, value_lens, add_ten)
# %LbfSeconds{value: %Rational{num: 110, den: 1}}
Here, we get 110 LbfSeconds.
And for newtons:
newton_10 = Lens.over!(newton, value_lens, add_ten)
# %NewtonSeconds{value: %Rational{num: 9096443230521, den: 20000000000}}
Let’s convert that back to a float:
newton_10
|> Lens.view!(value_lens)
|> Rational.to_float()
# 454.82216152605
And we have 454.822 NewtonSeconds, ten more than we started with.
But what is ten here? Is it ten lbf or ten newtons?
That’s what crashed the Mars Climate Orbiter.
Protect our Boundary
Let’s leverage a Prism to draw boundaries we can use to protect ourselves from making the mistake:
lbf_prism = Prism.path([{LbfSeconds, :value}])
newton_prism = Prism.path([{NewtonSeconds, :value}])
Now, within the context of LbfSeconds:
Prism.preview(lbf, lbf_prism)
# %Funx.Monad.Maybe.Just{value: %Rational{num: 100, den: 1}}
We have Just the value.
But with newtons:
Prism.preview(newton, lbf_prism)
# %Funx.Monad.Maybe.Nothing{}
We have Nothing. Our boundary will not let us update an lbf with a newton.
Let’s use this boundary in the Maybe dsl:
use Funx.Monad.Maybe
maybe lbf do
bind Prism.preview(lbf_prism)
map add_ten
map Rational.to_float
end
# %Funx.Monad.Maybe.Just{value: 110.0}
To add ten newtons, we need to switch over to the NewtonSeconds boundary:
maybe newton do
bind Prism.preview(newton_prism)
map add_ten
map Rational.to_float
end
# %Funx.Monad.Maybe.Just{value: 454.82216152605}
If we try to add ten LbfSeconds to our NewtonSeconds:
maybe newton do
bind Prism.preview(lbf_prism)
map add_ten
map Rational.to_float
end
# %Funx.Monad.Maybe.Nothing{}
Our boundary is stating that in the context of LbfSeconds a newton doesn’t exist, so it is Nothing.
But that’s not true: LbfSeconds and NewtonSeconds are NOT two separate things, they are the SAME thing. Just being viewed in two different ways. Impulse isn’t a Prism, it is an Iso.
Swap, don’t fail
With an Iso, we don’t fail when we’re in the wrong representation. We just swap to the correct context, do our work, and swap back:
lbf_add_10_newton =
Iso.view(lbf, impulse_iso)
|> Lens.over!(value_lens, add_ten)
|> Iso.review(impulse_iso)
# %LbfSeconds{value: %Rational{num: 909644323052100, den: 8896443230521}}
Here, we are swapping LbfSeconds over to the NewtonSeconds, then adding ten units, and swapping back to LbfSeconds.
Here is the float value:
lbf_add_10_newton
|> Lens.view!(value_lens)
|> Rational.to_float()
# 102.2480894309971
After adding ten NewtonSeconds, we now have about 102.248 LbfSeconds.
An Iso already has a function for this, named over/3:
lbf_add_10_newton =
Iso.over(
lbf,
impulse_iso,
fn newton -> Lens.over!(newton, value_lens, add_ten) end
)
# %LbfSeconds{value: %Rational{num: 909644323052100, den: 8896443230521}}
And it has the opposite with under/3, where we can add ten LbfSeconds to our NewtonSeconds:
newton_add_10_lbf =
Iso.under(
newton,
impulse_iso,
fn lbf -> Lens.over!(lbf, value_lens, add_ten) end
)
# %NewtonSeconds{value: %Rational{num: 97860875535731, den: 200000000000}}
If we get the difference:
Lens.view!(newton_add_10_lbf, value_lens)
|> Rational.subtract(Lens.view!(newton,value_lens))
|> Rational.to_float()
# 44.482216152605
We find 10 LbfSeconds is equal to about 44.4822 NewtonSeconds.
Wrapping Up
An iso is a statement that two representations are the same information.
That’s why isos don’t have bang variants. An iso models no failure. If the transformation can lose information or fail, we don’t have an iso. A crash is a broken invariant, not an expected outcome.
The Mars Climate Orbiter failed because the boundary between representations was implicit. With an iso, we can make that boundary explicit. Same thing, two views, guaranteed round-tripping.
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.