“I see dead people.” — The Sixth Sense (1999)

Run in Livebook

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.