“The problem is choice.” — Neo, The Matrix (1999)

Run in Livebook

Why Prism?

Like a Lens, a Prism is composable. In Elixir, it can also help manage missing keys or expected nils, but that is incidental. A Prism models conditional existence: a focus that exists only on certain branches of a sum type.

Let’s say we are building a system to process transactions. A transaction can be a purchase or a refund, and it can be paid by check or credit card.

The tricky part is not extracting fields. It is deciding which payment operations apply.

Transaction
└─ type
   ├─ Charge
   │  └─ payment
   │     ├─ CreditCard
   │     │  └─ amount   ← cc_payment
   │     └─ Check
   │        └─ amount   ← check_payment
   │
   └─ Refund
      └─ payment
         ├─ CreditCard
         │  └─ amount   ← cc_refund
         └─ Check
            └─ amount   ← check_refund

The path type.payment.amount does not identify a single thing. It identifies four distinct meanings, depending on which branch of the tree exists.

So amount is a field in the data, but it is not a single domain meaning. In this model, it can mean one of four things, and the meaning depends on context.

A Lens is the right tool when you have a product type: a structure where the relevant fields exist together. Here, we have a sum type: one of several possible shapes. Only one branch exists at a time.

In the domain, this is conditional existence. A check refund is a valid transaction. It is not an error case. It simply does not belong to the operation “charge a credit card.”

If we treat that mismatch as “bad data,” we reach for error handling, guards, or defensive code. That treats the symptom, not the problem.

A Prism states something sharper:

“This value exists, but only in this context.”

Learn more about making illegal states unrepresentable.

Building the Transaction Processor

alias Funx.Optics.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 Charge do
  defstruct [:payment]
  alias Funx.Optics.Prism

  def payment_prism do
    Prism.path([{__MODULE__, :payment}])
  end
end

defmodule Refund do
  defstruct [:payment]
  alias Funx.Optics.Prism

  def payment_prism do
    Prism.path([{__MODULE__, :payment}])
  end
end

defmodule Transaction do
  defstruct [:type]
  alias Funx.Optics.Prism

  def type_prism do
    Prism.path([{__MODULE__, :type}])
  end
end

And we need some data to work with:

cc_payment = %CreditCard{name: "John", number: "1234", expiry: "12/26", amount: 75}
check_payment = %Check{name: "Dave", routing_number: "111000025", account_number: "987654", amount: 125}

charge_cc = %Transaction{type: %Charge{payment: cc_payment}}
charge_check = %Transaction{type: %Charge{payment: check_payment}}
refund_cc = %Transaction{type: %Refund{payment: cc_payment}}
refund_check = %Transaction{type: %Refund{payment: check_payment}}

Trust the Caller

A common pattern in dynamic languages is to write the happy path and rely on callers to pass the right thing.

defmodule Trust.TransactionProcessor do
  def cc_payment(transaction) do
    Logger.info("Charge cc $#{transaction.type.payment.amount}")
    transaction
  end

  def check_payment(transaction) do
    Logger.info("Charge check $#{transaction.type.payment.amount}")
    transaction
  end

  def cc_refund(transaction) do
    Logger.info("Refund cc $#{transaction.type.payment.amount}")
    transaction
  end

  def check_refund(transaction) do
    Logger.info("Refund check $#{transaction.type.payment.amount}")
    transaction
  end
end

The caller can do the right thing:

Trust.TransactionProcessor.cc_payment(charge_cc)

# [info] Charge cc $75
#
# %Transaction{
#   type: %Charge{payment: %CreditCard{name: "John", number: "1234", expiry: "12/26", amount: 75}}
# }

And the caller can do the wrong thing:

Trust.TransactionProcessor.cc_payment(refund_cc)

# [info] Charge cc $75

# %Transaction{
#   type: %Refund{payment: %CreditCard{name: "John", number: "1234", expiry: "12/26", amount: 75}}
# }

Because the shape matches, nothing crashes. We silently applied the wrong operation to a valid transaction.

The bug is not “nil access.” The bug is “we ran a valid operation against the wrong meaning.”

Pattern Matching in Function Heads

The idiomatic Elixir fix is pattern matching in function heads:

defmodule Guarded.TransactionProcessor do
  def cc_payment(
        %Transaction{
          type: %Charge{
            payment: %CreditCard{amount: amount}
          }
        } = transaction
      ) do
    Logger.info("Charge cc $#{amount}")
    transaction
  end

  def check_payment(
        %Transaction{
          type: %Charge{
            payment: %Check{amount: amount}
          }
        } = transaction
      ) do
    Logger.info("Charge check $#{amount}")
    transaction
  end

  def cc_refund(
        %Transaction{
          type: %Refund{
            payment: %CreditCard{amount: amount}
          }
        } = transaction
      ) do
    Logger.info("Refund cc $#{amount}")
    transaction
  end

  def check_refund(
        %Transaction{
          type: %Refund{
            payment: %Check{amount: amount}
          }
        } = transaction
      ) do
    Logger.info("Refund check $#{amount}")
    transaction
  end
end

The boundary is enforced in the function head, and a mismatch raises when no clause matches.

This style has a few challenges:

  1. Intertwining domain and defensive logic makes intent harder to read.
  2. The logic is not shareable. When the contract changes, every function head that encodes it must be updated in lockstep.
  3. It behaves like a guard, but the rule is implicit. The constraint exists, but it is not named as a domain concept.

If we check the happy path:

Guarded.TransactionProcessor.cc_payment(charge_cc)

# [info] Charge cc $75
#
# %Transaction{
#   type: %Charge{payment: %CreditCard{name: "John", number: "1234", expiry: "12/26", amount: 75}}
# }

It works as before.

And we are treating a mismatch as a broken invariant, so it raises (fails fast):

Guarded.TransactionProcessor.cc_payment(refund_check)

# ** (FunctionClauseError) no function clause matching in Guarded.TransactionProcessor.cc_payment/1

Thinking Functionally

Prisms as Boundaries

A prism is a named boundary. It encodes the contract for when an operation applies.

Previewing through a prism is not “trying to get a field.” It is asking a sharper question:

Does this transaction exist as a credit card charge amount?

cc_payment_prism =
  Prism.path([
    {Transaction, :type},
    {Charge, :payment},
    {CreditCard, :amount}
  ])

This prism is composable, shareable, and testable.

So, is this transaction a cc payment?

Prism.preview(charge_cc, cc_payment_prism)

# %Funx.Monad.Maybe.Just{value: 75}

Yes. This transaction exists as a credit card charge amount, and we get the amount.

What about this one?

Prism.preview(refund_check, cc_payment_prism)

# %Funx.Monad.Maybe.Nothing{}

No.

The refund_check is not invalid. It simply does not exist in the context defined by cc_payment_prism.

Prisms also support review/2 for constructing values, but this post focuses on selection.

Organizing the Boundaries

Let’s compose the boundaries we care about:

defmodule Processor do
  def cc_payment_prism do
    Prism.compose([
      Transaction.type_prism,
      Charge.payment_prism,
      CreditCard.amount_prism
    ])
  end

  def check_payment_prism do
    Prism.compose([
      Transaction.type_prism,
      Charge.payment_prism,
      Check.amount_prism
    ])
  end

  def cc_refund_prism do
    Prism.compose([
      Transaction.type_prism,
      Refund.payment_prism,
      CreditCard.amount_prism
    ])
  end

  def check_refund_prism do
    Prism.compose([
      Transaction.type_prism,
      Refund.payment_prism,
      Check.amount_prism
    ])
  end
end

Now we can express our processors in terms of the prism boundaries:

defmodule Prism.TransactionProcessor do
  def cc_payment(transaction) do
    maybe transaction, as: :raise do
      bind Prism.preview(Processor.cc_payment_prism)
      tap fn amount -> Logger.info("Charge cc $#{amount}") end
      map fn (_) -> transaction end
    end
  end

  def check_payment(transaction) do
    maybe transaction, as: :raise do
      bind Prism.preview(Processor.check_payment_prism)
      tap fn amount -> Logger.info("Charge check $#{amount}") end
      map fn (_) -> transaction end
    end
  end

  def cc_refund(transaction) do
    maybe transaction, as: :raise do
      bind Prism.preview(Processor.cc_refund_prism)
      tap fn amount -> Logger.info("Refund cc $#{amount}") end
      map fn (_) -> transaction end
    end
  end

  def check_refund(transaction) do
    maybe transaction, as: :raise do
      bind Prism.preview(Processor.check_refund_prism)
      tap fn amount -> Logger.info("Refund check $#{amount}") end
      map fn (_) -> transaction end
    end
  end
end

The boundary is now a first-class value. It is reusable, composable, and enforced consistently wherever it is applied.

We still get the correct behavior for the happy path:

Prism.TransactionProcessor.cc_payment(charge_cc)

# [info] Charge cc $75
#
# %Transaction{
#   type: %Charge{payment: %CreditCard{name: "John", number: "1234", expiry: "12/26", amount: 75}}
# }

And we fail fast at the edge:

Prism.TransactionProcessor.cc_payment(refund_cc)

# ** (RuntimeError) Nothing value encountered

But the meaning is different. We fail at the boundary because “this operation does not apply in this context,” not because “the data is malformed.”

The Context of Monads

Prism’s preview/2 returns Maybe, which means we can leverage the monad to manage collections of values.

Let’s start with a list of transactions:

charge_cc_1 =
  %Transaction{
    type: %Charge{
      payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 1592}
    }
  }

charge_cc_2 =
  %Transaction{
    type: %Charge{
      payment: %CreditCard{name: "Bob", number: "4222", expiry: "11/25", amount: 823}
    }
  }

charge_cc_3 =
  %Transaction{
    type: %Charge{
      payment: %CreditCard{name: "Dave", number: "4444", expiry: "09/26", amount: 191}
    }
  }

refund_cc_1 =
  %Transaction{
    type: %Refund{
      payment: %CreditCard{name: "Carol", number: "4333", expiry: "10/27", amount: 161}
    }
  }

refund_cc_2 =
  %Transaction{
    type: %Refund{
      payment: %CreditCard{name: "Eve", number: "4555", expiry: "08/28", amount: 110}
    }
  }

transactions = [charge_cc_1, charge_cc_2, charge_cc_3, refund_cc_1, refund_cc_2]

Once the boundary is a value, we can reuse it across a collection: either to collect matches or to require that every element matches.

Collecting Matches

concat_map/2 keeps Just results and drops Nothing. This lets us collect only the credit card charges:

alias Funx.Monad.Maybe
alias Funx.Math

cc_payments =
  transactions
  |> Maybe.concat_map(&Prism.preview(&1, Processor.cc_payment_prism))
  |> Math.sum()

# 2606

And only the credit card refunds:

cc_refunds =
  transactions
  |> Maybe.concat_map(&Prism.preview(&1, Processor.cc_refund_prism))
  |> Math.sum()

# 271

From there, we can calculate our credit card net movement:

cc_payments - cc_refunds

# 2335

Requiring All Matches

traverse/2 flips the logic. Instead of collecting matches, it asserts that the entire list fits the prism.

With a mixed list, the answer is no:

transactions
|> Maybe.traverse(&Prism.preview(&1, Processor.cc_payment_prism))

# %Funx.Monad.Maybe.Nothing{}

If we narrow the list to only charges, it succeeds:

only_charges = [charge_cc_1, charge_cc_2, charge_cc_3]

only_charges
|> Maybe.traverse(&Prism.preview(&1, Processor.cc_payment_prism))

# %Funx.Monad.Maybe.Just{value: [1592, 823, 191]}

Now we get Just a list of credit card charge amounts.

That means we can write batch processors:

defmodule Batch.TransactionProcessor do
  alias Funx.Math
  require Logger

  def cc_payment(transactions) do
    maybe transactions, as: :raise do
      bind Maybe.traverse(&Prism.preview(&1, Processor.cc_payment_prism))
      tap fn amounts ->
        Logger.info("Charge cc total: $#{Math.sum(amounts)}")
      end
      map fn (_) -> transactions end
    end
  end

  def check_payment(transactions) do
    maybe transactions, as: :raise do
      bind Maybe.traverse(&Prism.preview(&1, Processor.check_payment_prism))
      tap fn amounts ->
        Logger.info("Charge check total: $#{Math.sum(amounts)}")
      end
      map fn (_) -> transactions end
    end
  end

  def cc_refund(transactions) do
    maybe transactions, as: :raise do
      bind Maybe.traverse(&Prism.preview(&1, Processor.cc_refund_prism))
      tap fn amounts ->
        Logger.info("Refund cc total: $#{Math.sum(amounts)}")
      end
      map fn (_) -> transactions end
    end
  end

  def check_refund(transactions) do
    maybe transactions, as: :raise do
      bind Maybe.traverse(&Prism.preview(&1, Processor.check_refund_prism))
      tap fn amounts ->
        Logger.info("Refund check total: $#{Math.sum(amounts)}")
      end
      map fn (_) -> transactions end
    end
  end
end

And with a mixed list:

Batch.TransactionProcessor.cc_payment(transactions)

# ** (RuntimeError) Nothing value encountered

This fails fast at the boundary.

But, with a homogeneous list of credit card charges:

Batch.TransactionProcessor.cc_payment(only_charges)

# [info] Charge cc total: $2606
#
# [
#   %Transaction{
#     type: %Charge{
#       payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 1592}
#     }
#   },
#   %Transaction{
#     type: %Charge{payment: %CreditCard{name: "Bob", number: "4222", expiry: "11/25", amount: 823}}
#   },
#   %Transaction{
#     type: %Charge{payment: %CreditCard{name: "Dave", number: "4444", expiry: "09/26", amount: 191}}
#   }
# ]

The batch runs as expected.

Elixir does not have a static way to express “this function accepts only a list of cc charge transactions.” But we can still enforce that domain constraint at the boundary. Maybe.traverse is all or nothing: either every element matches the prism and we get the extracted amounts, or the entire batch is rejected.

We are no longer treating a list of transactions as a bag of stuff and hoping conventions keep it aligned. We are reusing our existing prisms to assert a batch contract.

Now the defensive checks are isolated, named, and reusable, which keeps the business steps easy to read and maintain.

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.