Flattening Error Paths: When Elixir's `with` Beats Nested `case`
Elixir's `with` flattens chained pattern matches, but silent short-circuiting can hide partial successes. Learn when to use it over nested `case` and how to test both paths.
08 Feb 2026, 12:04 UTC

The pyramid of doom
When you chain operations that can fail in Elixir, the idiomatic return value is usually an {:ok, result} or {:error, reason} tuple. If you need to run three such steps in sequence, a naive implementation nests case expressions. Each level adds indentation, and the actual business logic drifts further to the right. The failure paths are easy to write, but the success path becomes hard to read.
What with actually does
Elixir's with special form evaluates a list of expressions in order. Each expression is pattern-matched against the pattern on its left—meaning Elixir checks the shape of the value and binds names to its parts. If every match succeeds, the do: block runs. If any match fails, execution short-circuits to the first matching else clause, or returns the non-matching value if no else is present.
This means you can describe the happy path as a flat list of preconditions. Variables bound in earlier clauses are available to later ones, so you do not need to unpack tuples manually at every step.
Worked example: parsing a config value
Suppose you have a plain-text file that should contain a single port number. You need to read the file, trim whitespace, and convert the string to an integer.
Here is the nested case version:
def parse_port(path) do
case File.read(path) do
{:ok, contents} ->
trimmed = String.trim(contents)
case Integer.parse(trimmed) do
{port, ""} when port > 0 and port < 65536 ->
{:ok, port}
_ ->
{:error, :invalid_port}
end
{:error, reason} ->
{:error, reason}
end
end
And here is the same logic with with:
def parse_port(path) do
with {:ok, contents} <- File.read(path),
{port, ""} <- Integer.parse(String.trim(contents)),
true <- port > 0 and port < 65536 do
{:ok, port}
else
{:error, reason} -> {:error, reason}
_ -> {:error, :invalid_port}
end
end
The second version keeps every success step at the same indentation level. The else block centralizes the two failure modes: a file system error or an invalid integer.
Running the example
You can verify this in a Mix project. Create one if you do not have it already:
mix new config_parser
cd config_parser
Place the function in lib/config_parser.ex. Then start an interactive shell with the project compiled:
iex -S mix
Inside iex, write a temporary file and call the function:
File.write!("/tmp/port.txt", "8080\n")
This requires write access to the target directory; use a path you own if /tmp is restricted. Then call the parser:
ConfigParser.parse_port("/tmp/port.txt")
# Expected check: returns {:ok, 8080}
Then test the failure path by pointing at a missing file:
ConfigParser.parse_port("/tmp/missing.txt")
# Expected check: returns {:error, :enoent}
The exact error atom depends on the operating system; :enoent is common on Unix-like systems when a file does not exist.
The hidden cost: partial successes
The most important limitation of with is that it is all-or-nothing. If the first clause matches but the second fails, the first side effect has already happened. In the example above, File.read/1 has no side effect, so skipping the rest is safe. But if an earlier step writes to a database or sends a message, a later failure leaves the system in a partially modified state.
Because with short-circuits silently, it can also hide which step failed unless your else clauses distinguish between them. A single catch-all _ -> {:error, :bad_input} tells the caller nothing about whether the file was missing or the number was malformed.
When to skip with
with earns its keep when you have two or more steps that share a common failure shape and you want the success path to read top-to-bottom. For a single check, a plain case or if is often clearer. Adding with around one expression adds ceremony without removing nesting.
Also avoid with when the intermediate steps have side effects that must be rolled back on failure. Elixir's with does not provide transactions or rollback; it only controls pattern-match flow.
A practical checklist
- Use
withwhen chaining two or more pattern-matched expressions that return tagged tuples (values like{:ok, result}or{:error, reason}). - Always write an
elseblock if the caller needs to know why the chain stopped. - Keep side effects out of
withclauses unless a later failure is harmless. - For single-step validation, prefer
case.
If you want to confirm the behavior in your own project, add a test that exercises both the valid and invalid paths:
# test/config_parser_test.exs
test "parses a valid port" do
assert {:ok, 8080} = ConfigParser.parse_port("test/fixtures/port.txt")
end
test "returns error for missing file" do
assert {:error, _} = ConfigParser.parse_port("test/fixtures/missing.txt")
end
Run the suite with mix test. A passing run confirms that your else clauses are reachable and that the success path returns the expected tuple.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.