Funx: Adding the Optic Traversal
“You’re either in or you’re out.” — Ocean’s Eleven (2001)
Why Traversal?
A Lens is a required focus: if the focus does not exist, that is an invariant violation.
A Prism is a Maybe focus: the branch either matches and yields a focus, or it does not.
A Lens and a Prism define a single focus, but sometimes we need multiple foci. That is a job for Traversal.
When we hear “traversal” we usually think “iterate a list” or “walk a tree.” That is not the right mental model for an optic traversal. A traversal does not describe how to walk the structure. It names multiple foci in the same structure.
The Problem
Let’s continue our system for processing transactions. A transaction can be a charge or a refund, and it can be paid by check or credit card.
What’s new is that each transaction now has an item with a price:
Transaction
├─ item
│ ├─ name
│ └─ price
└─ type
├─ Charge
│ ├─ payment
│ │ ├─ CreditCard
│ │ │ └─ amount ← cc_payment
│ │ └─ Check
│ │ └─ amount ← check_payment
│ └─ status
│
└─ Refund
├─ payment
│ ├─ CreditCard
│ │ └─ amount ← cc_refund
│ └─ Check
│ └─ amount ← check_refund
└─ status
Building the Domain Model
First, let’s define our domain structures:
alias Funx.Optics.{Lens, Prism}
require Logger
use Funx.Monad.Maybe
defmodule CreditCard do
defstruct [:name, :number, :expiry, :amount]
alias Funx.Optics.Prism
def amount_prism do
Prism.path([{__MODULE__, :amount}])
end
end
defmodule Check do
defstruct [:name, :routing_number, :account_number, :amount]
alias Funx.Optics.Prism
def amount_prism do
Prism.path([{__MODULE__, :amount}])
end
end
defmodule Item do
defstruct [:name, :price]
end
defmodule Charge do
defstruct [:payment, :status]
alias Funx.Optics.Prism
def payment_prism do
Prism.path([{__MODULE__, :payment}])
end
end
defmodule Refund do
defstruct [:payment, :status]
alias Funx.Optics.Prism
def payment_prism do
Prism.path([{__MODULE__, :payment}])
end
end
defmodule Transaction do
defstruct [:item, :type]
alias Funx.Optics.Prism
def type_prism do
Prism.path([{__MODULE__, :type}])
end
end
Next, some transactions:
charge_cc =
%Transaction{
item: %Item{name: "Camera", price: 500},
type: %Charge{
payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
status: :pending
}
}
invalid_charge_cc =
%Transaction{
item: %Item{name: "Camera", price: 500},
type: %Charge{
payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 400},
status: :pending
}
}
charge_check =
%Transaction{
item: %Item{name: "Lens", price: 300},
type: %Charge{
payment: %Check{name: "Bob", routing_number: "111000025", account_number: "987654", amount: 300},
status: :pending
}
}
refund_cc =
%Transaction{
item: %Item{name: "Tripod", price: 150},
type: %Refund{
payment: %CreditCard{name: "Carol", number: "4333", expiry: "10/27", amount: 150},
status: :pending
}
}
refund_check =
%Transaction{
item: %Item{name: "Flash", price: 200},
type: %Refund{
payment: %Check{name: "Dave", routing_number: "222000025", account_number: "123456", amount: 200},
status: :pending
}
}
transactions = [charge_cc, charge_check, refund_cc, refund_check]
Our domain requires that a transaction’s item price match its payment amount. We don’t need a single focus: we need both the item and the payment.
This is a boundary problem: we want to prevent an invalid transaction from being processed.
Process a Transaction
In Elixir, the usual way to do that is to protect the boundary in the function head, using pattern matching to ensure the required shape before any work happens.
process_basic = fn
%Transaction{
item: %Item{price: price, name: name},
type: %{payment: %{amount: amount}} = type
} = transaction
when price == amount ->
Logger.info("Processing $#{amount} for #{name}")
%{transaction | type: %{type | status: :complete}}
end
This function extracts the price and amount, validates that they match in the guard, and completes the transaction:
process_basic.(charge_cc)
# [info] Processing $500 for Camera
#
# %Transaction{
# item: %Item{name: "Camera", price: 500},
# type: %Charge{
# payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
# status: :complete
# }
# }
Here, the happy path succeeds.
And the invalid transaction raises:
process_basic.(invalid_charge_cc)
# ** (FunctionClauseError) no function clause matching in :erl_eval."
Putting the domain rule in the function head works, but there is no way to extract this domain logic to test and share.
Thinking Functionally
Let’s use a traversal:
alias Funx.Optics.{Lens, Traversal}
item_and_payment_trav =
Traversal.combine([
Lens.path([:item]),
Lens.path([:type, :payment])
])
We can use Traversal.to_list/2 to get both values:
charge_cc |> Traversal.to_list(item_and_payment_trav)
# [
# %Item{name: "Camera", price: 500},
# %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500}
# ]
Here, we receive a list of foci: the Item and the CreditCard.
And we can extend our pipe to check the domain rule:
charge_cc
|> Traversal.to_list(item_and_payment_trav)
|> then(fn [item, payment] ->
item.price == payment.amount
end)
# true
Here, the happy path is true.
invalid_charge_cc
|> Traversal.to_list(item_and_payment_trav)
|> then(fn [item, payment] ->
item.price == payment.amount
end)
# false
And the unhappy path is false.
Maybe DSL
Let’s implement this logic in the Maybe DSL:
process_with_traversal = fn transaction ->
maybe transaction, as: :raise do
bind Traversal.to_list_maybe(item_and_payment_trav)
guard fn [item, payment] -> item.price == payment.amount end
tap fn [item, payment] ->
Logger.info("Processing $#{payment.amount} for #{item.name}")
end
bind fn _val -> Lens.set(transaction, Lens.path([:type, :status]), :complete) end
end
end
Here, we are using to_list_maybe/2 to lift the results of our traversal into the Maybe context. Next, we narrow the boundary with a guard implementing the domain rule. Then we log what we plan to do, and finally we update our transaction status to :complete.
Our happy path still works:
process_with_traversal.(charge_cc)
# [info] Processing $500 for Camera
#
# %Transaction{
# item: %Item{name: "Camera", price: 500},
# type: %Charge{
# payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
# status: :complete
# }
# }
And our unhappy path continues to fail quickly:
process_with_traversal.(invalid_charge_cc)
# ** (RuntimeError) Nothing value encountered
Now our boundary rules are easy to update, test, and share, which becomes more important as complexity grows.
Implementing Sum Types
Let’s extend our boundary to the payment type. Not just a payment, but the specific payment branch, such as “refund credit card.”
Again, the idiomatic Elixir approach is to encode the boundary logic directly in the function head.
defmodule Guarded.TransactionProcessor do
def cc_payment(
%Transaction{
item: %Item{price: item_price, name: name},
type: %Charge{
payment: %CreditCard{amount: payment_amount}
} = charge
} = transaction
) when item_price == payment_amount do
Logger.info("Charge cc $#{payment_amount} for #{name}")
%{
transaction
| type: %{charge | status: :complete}
}
end
def check_payment(
%Transaction{
item: %Item{price: item_price, name: name},
type: %Charge{
payment: %Check{amount: payment_amount}
} = charge
} = transaction
) when item_price == payment_amount do
Logger.info("Charge check $#{payment_amount} for #{name}")
%{
transaction
| type: %{charge | status: :complete}
}
end
def cc_refund(
%Transaction{
item: %Item{price: item_price, name: name},
type: %Refund{
payment: %CreditCard{amount: payment_amount}
} = refund
} = transaction
) when item_price == payment_amount do
Logger.info("Refund cc $#{payment_amount} for #{name}")
%{
transaction
| type: %{refund | status: :complete}
}
end
def check_refund(
%Transaction{
item: %Item{price: item_price, name: name},
type: %Refund{
payment: %Check{amount: payment_amount}
} = refund
} = transaction
) when item_price == payment_amount do
Logger.info("Refund check $#{payment_amount} for #{name}")
%{
transaction
| type: %{refund | status: :complete}
}
end
end
There is nothing particularly wrong with this logic, but if we need it in other places we will need to copy and paste. In fact, we are copying and pasting our domain rule item_price == payment_amount four times in this module alone.
Our happy path works:
Guarded.TransactionProcessor.cc_payment(charge_cc)
# [info] Charge cc $500 for Camera
#
# %Transaction{
# item: %Item{name: "Camera", price: 500},
# type: %Charge{
# payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
# status: :complete
# }
# }
The invalid charge still raises:
Guarded.TransactionProcessor.cc_payment(invalid_charge_cc)
# ** (FunctionClauseError) no function clause matching in Guarded.TransactionProcessor.cc_payment/1
And now a payment mismatch also raises:
Guarded.TransactionProcessor.cc_payment(refund_check)
# ** (FunctionClauseError) no function clause matching in Guarded.TransactionProcessor.cc_payment/1
Our function head is doing three things at once:
- It selects the shape required by the operation.
- It extracts the foci needed by the operation.
- It enforces the domain rule that relates those foci.
Again, it works, but it is not a reusable boundary. If the same applicability rule matters in another workflow, the rule has to be re-expressed in a new function head. Worse, when the domain changes, we need to find and update all the rules in lockstep.
The Functional Way
First, let’s compose the traversals we care about:
defmodule Processor do
alias Funx.Optics.{Lens, Prism, Traversal}
def item_lens do
Lens.path([:item])
end
def cc_payment_trav do
Traversal.combine([
Processor.item_lens,
Prism.compose([
Transaction.type_prism,
Charge.payment_prism,
Prism.struct(CreditCard)
])
])
end
def check_payment_trav do
Traversal.combine([
Processor.item_lens,
Prism.compose([
Transaction.type_prism,
Charge.payment_prism,
Prism.struct(Check)
])
])
end
def cc_refund_trav do
Traversal.combine([
Processor.item_lens,
Prism.compose([
Transaction.type_prism,
Refund.payment_prism,
Prism.struct(CreditCard)
])
])
end
def check_refund_trav do
Traversal.combine([
Processor.item_lens,
Prism.compose([
Transaction.type_prism,
Refund.payment_prism,
Prism.struct(Check)
])
])
end
def payment_status_lens do
Lens.path([:type, :status])
end
end
Here, we are no longer using a Lens for our payments. Instead we express the sum type with a Prism. The item must exist, and the payment branch might exist.
Domain validation
Let’s extract our guard:
defmodule PaymentMustMatchPrice do
def run_maybe([item, payment], _opts, _env) do
item.price == payment.amount
end
end
Logging
And isolate our log:
defmodule LogTransaction do
def run_maybe([item, payment], opts, _env) do
prefix = Keyword.get(opts, :prefix)
Logger.info("#{prefix} $#{payment.amount} for #{item.name}")
:ok
end
end
Completing the transaction
And our update logic as well:
defmodule CompleteTransaction do
alias Funx.Optics.Lens
def run_maybe(_foci, opts, _env) do
transaction = Keyword.get(opts, :original)
Lens.set(transaction, Processor.payment_status_lens, :complete)
end
end
Declarative Processor
Now we can make a much more declarative processor:
defmodule Declarative.TransactionProcessor do
alias Funx.Optics.Traversal
def cc_payment(transaction) do
maybe transaction, as: :raise do
bind Traversal.to_list_maybe(Processor.cc_payment_trav)
guard PaymentMustMatchPrice
tap {LogTransaction, prefix: "Charging cc"}
bind {CompleteTransaction, original: transaction}
end
end
def check_payment(transaction) do
maybe transaction, as: :raise do
bind Traversal.to_list_maybe(Processor.check_payment_trav)
guard PaymentMustMatchPrice
tap {LogTransaction, prefix: "Charging check"}
bind {CompleteTransaction, original: transaction}
end
end
def cc_refund(transaction) do
maybe transaction, as: :raise do
bind Traversal.to_list_maybe(Processor.cc_refund_trav)
guard PaymentMustMatchPrice
tap {LogTransaction, prefix: "Refunding cc"}
bind {CompleteTransaction, original: transaction}
end
end
def check_refund(transaction) do
maybe transaction, as: :raise do
bind Traversal.to_list_maybe(Processor.check_refund_trav)
guard PaymentMustMatchPrice
tap {LogTransaction, prefix: "Refunding check"}
bind {CompleteTransaction, original: transaction}
end
end
end
Here:
Traversal.to_list_maybe/2lifts our traversal into theMaybecontext, and enforces that either all foci exist together (Just) or one or more prisms are missing (Nothing).guard/2further reduces the boundary with our domain rule.tap/2calls the log effect, but does not fail the pipeline.CompleteTransactiondefines an action that may fail.
Again, our happy path succeeds:
Declarative.TransactionProcessor.cc_payment(charge_cc)
# [info] Charge cc $500 for Camera
#
# %Transaction{
# item: %Item{name: "Camera", price: 500},
# type: %Charge{
# payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
# status: :complete
# }
# }
The invalid credit card charge raises:
Declarative.TransactionProcessor.cc_payment(refund_cc)
# ** (RuntimeError) Nothing value encountered
And so does the mismatch:
Declarative.TransactionProcessor.cc_payment(invalid_charge_cc)
# ** (RuntimeError) Nothing value encountered
This is not about replacing pattern matching. Pattern matching remains a strong tool. The difference is that the requirements for an operation can exist as values, which lets the code stay declarative: named, reusable, testable, and composable.
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.