“You’re looking at it wrong.” — The Big Lebowski (1998)

In the previous post, we built an iso between LbfSeconds and NewtonSeconds, flat structs with a single value. Our spacecraft has nested data: impulse values inside thruster telemetry and navigation.

This post shows how Iso composes with Lens, Prism, and Traversal to work with nested and optional data. Then we’ll apply the same pattern to a different problem: isolating vendor decisions at system boundaries.

Run in Livebook

Nested Data

Spacecraft
│
├─ id
│
├─ thrusters
│  ├─ impulse
│  │  └─ LbfSeconds
│  │     └─ value
│  ├─ fuel_remaining
│  └─ status
│
└─ navigation
   ├─ target_impulse
   │  └─ NewtonSeconds
   │     └─ value
   ├─ trajectory
   └─ eta

Our spacecraft has thrusters and navigation, two subsystems that work with impulse values.

defmodule ThrusterData do
  defstruct [:impulse, :fuel_remaining, :status]
end

defmodule NavData do
  defstruct [:target_impulse, :trajectory, :eta]
end

defmodule Spacecraft do
  defstruct [:id, :thrusters, :navigation]
end

The thruster team uses pound-force seconds, and the navigation team uses newton-seconds:

spacecraft = %Spacecraft{
  id: "MCO-1999",
  thrusters: %ThrusterData{
    impulse: %LbfSeconds{value: Rational.from_integer(100)},
    fuel_remaining: 75.5,
    status: :active
  },
  navigation: %NavData{
    target_impulse: %NewtonSeconds{value: Rational.from_integer(501)},
    trajectory: :mars_orbit,
    eta: ~U[1999-09-23 09:01:00Z]
  }
}

# %Spacecraft{
#   id: "MCO-1999",
#   thrusters: %ThrusterData{
#     impulse: %LbfSeconds{value: %Rational{num: 100, den: 1}},
#     fuel_remaining: 75.5,
#     status: :active
#   },
#   navigation: %NavData{
#     target_impulse: %NewtonSeconds{value: %Rational{num: 501, den: 1}},
#     trajectory: :mars_orbit,
#     eta: ~U[1999-09-23 09:01:00Z]
#   }
# }

Let’s get the thruster impulse first:

alias Funx.Optics.{Lens, Iso, Prism, Traversal}


thrusters_impulse_lens = Lens.path([:thrusters, :impulse])
spacecraft |> Lens.view!(thrusters_impulse_lens)

# %LbfSeconds{value: %Rational{num: 100, den: 1}}

This gives us LbfSeconds. The navigation subsystem works in NewtonSeconds.

An Iso can be converted to Lens with as_lens/1:

thruster_impulse_ns_lens =
  Lens.compose([
    thrusters_impulse_lens,
    Iso.as_lens(Impulse.impulse_iso())
  ])

Which lets us view the thruster impulse as NewtonSeconds:

spacecraft |> Lens.view!(thruster_impulse_ns_lens)

# %NewtonSeconds{value: %Rational{num: 8896443230521, den: 20000000000}}

We can get the navigation target impulse using a lens as well:

navigation_impulse_ns_lens = Lens.path([:navigation, :target_impulse])
spacecraft |> Lens.view!(navigation_impulse_ns_lens)

# %NewtonSeconds{value: %Rational{num: 501, den: 1}}

And we can get both with Traversal:

alias Funx.Optics.Traversal

thrust_trav = 
  Traversal.combine([
    thruster_impulse_ns_lens,
    navigation_impulse_ns_lens
  ])

[current_thrust, intended_thrust] = Traversal.to_list(spacecraft, thrust_trav)

# [
#   %NewtonSeconds{value: %Rational{num: 8896443230521, den: 20000000000}},
#   %NewtonSeconds{value: %Rational{num: 501, den: 1}}
# ]

The values don’t match. This is the gap between what the thrusters are actually doing and what navigation believes is happening:

diff = Rational.subtract(intended_thrust.value, current_thrust.value)
Rational.to_float(diff)

# 56.17783847395

Our current thrust is about 56.2 NewtonSeconds short.

We can update the thruster impulse to match the target by updating through the lens path that includes the newton-second view.

thruster_impulse_ns_value_lens =
  Lens.compose(thruster_impulse_ns_lens, Lens.key(:value))

 updated_spacecraft =
   Lens.over!(
    spacecraft,
    thruster_impulse_ns_value_lens,
    fn current -> Rational.add(current, diff) end)

# %Spacecraft{
#   id: "MCO-1999",
#   thrusters: %ThrusterData{
#     impulse: %LbfSeconds{value: %Rational{num: 1002000000000000, den: 8896443230521}},
#     fuel_remaining: 75.5,
#     status: :active
#   },
#   navigation: %NavData{
#     target_impulse: %NewtonSeconds{value: %Rational{num: 501, den: 1}},
#     trajectory: :mars_orbit,
#     eta: ~U[1999-09-23 09:01:00Z]
#   }
# }

Notice we can’t just add the float 56.177..., since it has lost information. Instead, we use diff:

[current_thrust, intended_thrust] = Traversal.to_list(updated_spacecraft, thrust_trav)

# [
#   %NewtonSeconds{value: %Rational{num: 501, den: 1}},
#   %NewtonSeconds{value: %Rational{num: 501, den: 1}}
# ]

And now our values match.

The Lens and Iso ensure that unit conversions are explicit, reversible, and mechanically correct.

Working with Optional Data

Space communications are unreliable. Solar flares, distance, and hardware faults mean our navigation module sometimes drops out. When it’s online, we have navigation data. When it’s not, the field is nil.

spacecraft_connected = %Spacecraft{
  id: "MCO-1999",
  thrusters: %ThrusterData{
    impulse: %LbfSeconds{value: Rational.from_integer(45)},
    fuel_remaining: 75.5,
    status: :active
  },
  navigation: %NavData{
    target_impulse: %NewtonSeconds{value: Rational.from_integer(375)},
    trajectory: :mars_orbit,
    eta: ~U[1999-09-23 09:01:00Z]
  }
}

spacecraft_disconnected = %Spacecraft{
  id: "MCO-1999",
  thrusters: %ThrusterData{
    impulse: %LbfSeconds{value: Rational.from_integer(76)},
    fuel_remaining: 75.5,
    status: :active
  },
  navigation: nil  # Communication lost
}

We need to check whether thruster impulse matches the navigation target, but we can’t assume navigation data exists.

A Prism handles optional access: it may or may not find a focus. An Iso witnesses type equivalence: the conversion is total and reversible. We can compose them, with the prism handling the optional data and the iso managing the unit conversion.

An Iso can act as a Prism, so we convert it with as_prism/1:

nav_target_prism =
  Prism.path([:navigation, :target_impulse])

With a good connection, we get Just the target:

Prism.preview(spacecraft_connected, nav_target_prism)

# %Funx.Monad.Maybe.Just{value: %NewtonSeconds{value: %Rational{num: 375, den: 1}}}

With a lost connection, we get Nothing:

Prism.preview(spacecraft_disconnected, nav_target_prism)

# %Funx.Monad.Maybe.Nothing{}

But our thruster module needs pound-force seconds. Iso.from/1 flips the direction converting NewtonSeconds into LbfSeconds:

nav_target_lbf_prism =
  Prism.compose(
    nav_target_prism,
    Iso.as_prism(Iso.from(Impulse.impulse_iso())))

Prism.preview(spacecraft_connected, nav_target_lbf_prism)

# %Funx.Monad.Maybe.Just{
#   value: %LbfSeconds{value: %Rational{num: 750000000000000, den: 8896443230521}}
# }

Now that we are in the context of Maybe, we can use Maybe.traverse:

alias Funx.Monad.Maybe

fleet = [spacecraft, spacecraft_connected]

fleet
|> Maybe.traverse(fn ship -> Prism.preview(ship, nav_target_lbf_prism) end)

# %Funx.Monad.Maybe.Just{
#   value: [
#     %LbfSeconds{value: %Rational{num: 1002000000000000, den: 8896443230521}},
#     %LbfSeconds{value: %Rational{num: 750000000000000, den: 8896443230521}}
#   ]
# }

When every spacecraft in our fleet has nav data, we get Just the list of nav targets.

If we add our disconnected spacecraft:

fleet = [spacecraft_connected, spacecraft_disconnected]

fleet
|> Maybe.traverse(fn ship -> Prism.preview(ship, nav_target_prism) end)

# %Funx.Monad.Maybe.Nothing{}

Now the traversal fails because one spacecraft has no nav data. We get Nothing.

The prism handles optionality. The iso handles type equivalence.

Isolating Decisions

We’re in the early stages of our project. The team wants to use SuperGIS. It gets us moving quickly and has a free tier. But if we succeed, it gets expensive. The worst case: moderate success, where SuperGIS costs shorten our runway before we can switch.

We need to choose now without losing the ability to change later.

SuperGis.Feature and GeoJson.Feature are isomorphic types. They encode the same geographic information in different schemas. If we make that isomorphism explicit, we can defer the vendor decision.

The data from SuperGIS looks like this:

defmodule SuperGis.Point do
  defstruct [:x, :y]
end

defmodule SuperGis.Attributes do
  defstruct [
    :object_id,
    :name,
    :category,
    :rating,
    :created_at
  ]
end

defmodule SuperGis.Feature do
  defstruct [
    geometry: SuperGis.Point,
    attributes: SuperGis.Attributes
  ]
end

We don’t know what we’ll choose later, so we pick GeoJson as our internal type: a general schema that most GIS platforms can map to.

Here is our GeoJson:

defmodule GeoJson.Point do
  defstruct [:lon, :lat]
end

defmodule GeoJson.Properties do
  defstruct [
    :id,
    :name,
    :category,
    :rating,
    :created_at
  ]
end

defmodule GeoJson.Feature do
  defstruct [
    geometry: GeoJson.Point,
    properties: GeoJson.Properties
  ]
end

Now we define the isomorphism. SuperGis.Point and GeoJson.Point are isomorphic: same coordinates, different field names:

point_iso =
  Iso.make(
    fn %SuperGis.Point{x: x, y: y} ->
      %GeoJson.Point{lon: x, lat: y}
    end,
    fn %GeoJson.Point{lon: lon, lat: lat} ->
      %SuperGis.Point{x: lon, y: lat}
    end
  )

SuperGis.Attributes and GeoJson.Properties are also isomorphic: same data, different names for the container and the id field:

attributes_iso =
  Iso.make(
    fn %SuperGis.Attributes{
         object_id: id,
         name: name,
         category: category,
         rating: rating,
         created_at: created_at
       } ->
      %GeoJson.Properties{
        id: id,
        name: name,
        category: category,
        rating: rating,
        created_at: created_at
      }
    end,
    fn %GeoJson.Properties{
         id: id,
         name: name,
         category: category,
         rating: rating,
         created_at: created_at
       } ->
      %SuperGis.Attributes{
        object_id: id,
        name: name,
        category: category,
        rating: rating,
        created_at: created_at
      }
    end
  )

Finally, the feature iso composes the point and attribute isos. SuperGis.Feature and GeoJson.Feature are isomorphic because their components are:

feature_iso =
  Iso.make(
    fn %SuperGis.Feature{geometry: geom, attributes: attrs} ->
      %GeoJson.Feature{
        geometry: Iso.view(geom, point_iso),
        properties: Iso.view(attrs, attributes_iso)
      }
    end,
    fn %GeoJson.Feature{geometry: geom, properties: props} ->
      %SuperGis.Feature{
        geometry: Iso.review(geom, point_iso),
        attributes: Iso.review(props, attributes_iso)
      }
    end
  )

Now when we receive a SuperGIS feature:

super_gis_feature =
  %SuperGis.Feature{
    geometry: %SuperGis.Point{
      x: -122.335167,
      y: 47.608013
    },
    attributes: %SuperGis.Attributes{
      object_id: 1,
      name: "Coffee Shop",
      category: "Retail",
      rating: 4.6,
      created_at: ~U[2023-08-15 00:00:00Z]
    }
  }

# %SuperGis.Feature{
#   geometry: %SuperGis.Point{x: -122.335167, y: 47.608013},
#   attributes: %SuperGis.Attributes{
#     object_id: 1,
#     name: "Coffee Shop",
#     category: "Retail",
#     rating: 4.6,
#     created_at: ~U[2023-08-15 00:00:00Z]
#   }
# }

We can view/2 it as GeoJson:

geo_json_feature = Iso.view(super_gis_feature, feature_iso)

# %GeoJson.Feature{
#   geometry: %GeoJson.Point{lon: -122.335167, lat: 47.608013},
#   properties: %GeoJson.Properties{
#     id: 1,
#     name: "Coffee Shop",
#     category: "Retail",
#     rating: 4.6,
#     created_at: ~U[2023-08-15 00:00:00Z]
#   }
# }

And we can go back:

Iso.review(geo_json_feature, feature_iso)

# %SuperGis.Feature{
#   geometry: %SuperGis.Point{x: -122.335167, y: 47.608013},
#   attributes: %SuperGis.Attributes{
#     object_id: 1,
#     name: "Coffee Shop",
#     category: "Retail",
#     rating: 4.6,
#     created_at: ~U[2023-08-15 00:00:00Z]
#   }
# }

Inside our system, we work with GeoJson.Feature. At the boundary with SuperGIS, the iso converts. If we later add a different vendor, we write a new iso between that vendor’s types and GeoJson. Our internal code doesn’t change.

More importantly, we don’t need to migrate all at once. Because the isomorphism is explicit, we can replace SuperGIS calls incrementally: the most expensive ones first, while keeping the system coherent.

Wrapping Up

Lens focuses on nested data and lets you convert it through an iso. Prism handles optional data, with the iso performing the conversion only when a focus exists. Traversal lets you work with multiple foci across a structure, each one viewed through the same iso.

Whether you’re converting units between subsystems or schemas between vendors, the iso does one job: it makes the equivalence explicit and provides total, reversible functions to move between representations.

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.

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.