The with
special form provides a clean way to chain operations where each step depends on the successful completion of the previous one. This article explores how to effectively use with
to create more readable and maintainable code, particularly when handling complex series of operations that may fail at any point.
Note: The examples in this article use Elixir 1.17.3. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Understanding With Expressions
- Error Handling with With
- Pattern Matching in With
- Common Use Cases
- Best Practices
- Conclusion
- Further Reading
- Next Steps
Introduction
In our previous articles, we explored various control flow structures in Elixir:
-
if
andunless
for simple conditional logic -
case
for pattern matching against values -
cond
for evaluating multiple conditions
Now, we'll examine the with
special form, which addresses a common problem in functional programming: handling a sequence of operations where each depends on the previous one's success.
In procedural languages, this often leads to deeply nested conditional statements or extensive error checking. Elixir's with
structure provides an elegant solution that keeps code flat while maintaining proper error handling.
Understanding With Expressions
The with
special form in Elixir combines pattern matching and sequential binding to create a clean control flow structure. It allows you to process a series of operations in sequence, binding their results to variables along the way, and handling any failures in a centralized manner.
Basic With Syntax
# Basic structure:
result = with pattern1 <- expression1,
pattern2 <- expression2,
pattern3 <- expression3 do
# Block executed only if all patterns match
success_expression
end
# If any pattern doesn't match, the non-matching value is returned immediately
Let's look at a simple example:
iex> with {:ok, number} <- {:ok, 42},
double = number * 2,
{:ok, squared} <- {:ok, double * double} do
"Result: #{squared}"
end
# Output
"Result: 7056"
In this example:
-
{:ok, number}
matches{:ok, 42}
, bindingnumber
to42
- We calculate
double
asnumber * 2
(84) -
{:ok, squared}
matches{:ok, double * double}
, bindingsquared
to7056
- The do-block executes, returning the result string
Now, let's see what happens when a pattern doesn't match:
iex> with {:ok, number} <- {:error, :not_found},
double = number * 2,
{:ok, squared} <- {:ok, double * double} do
"Result: #{squared}"
end
# Output
{:error, :not_found}
The {:ok, number}
pattern doesn't match {:error, :not_found}
, so the with
expression immediately returns the non-matching value without executing the remaining steps.
Using Else to Handle Non-Matching Patterns
We can add an else
clause to handle non-matching patterns:
iex> with {:ok, number} <- {:error, :not_found},
double = number * 2,
{:ok, squared} <- {:ok, double * double} do
"Result: #{squared}"
else
{:error, :not_found} -> "Number not found"
_ -> "Unknown error"
end
# Output
"Number not found"
The else
clause uses pattern matching similar to case
:
iex> with {:ok, value} <- {:ok, 42} do
value * 2
else
{:ok, 0} -> "Got zero"
{:error, reason} -> "Error: #{reason}"
unexpected -> "Unexpected: #{inspect(unexpected)}"
end
# Output
84
Assignment vs. Pattern Matching in With
with
supports two types of clauses:
- Pattern matching with
<-
: Checks if the right side matches the pattern on the left - Regular assignment with
=
: Always succeeds and binds the variable
iex> with {:ok, a} <- {:ok, 1}, # Pattern matching (must match)
b = a + 1, # Assignment (always succeeds)
{:ok, c} <- {:ok, b + 1} do # Pattern matching (must match)
a + b + c
end
# Output
6
Error Handling with With
One of the main benefits of with
is its ability to handle errors in a clean, centralized way.
Without Else Clause
Without an else
clause, with
returns the value of the first expression that doesn't match its pattern:
iex> result = with {:ok, num} <- {:error, :invalid},
{:ok, doubled} <- {:ok, num * 2} do
"Success: #{doubled}"
end
# Output
{:error, :invalid}
This behavior is particularly useful with functions that return tagged tuples like {:ok, result}
or {:error, reason}
.
With Else Clause
Adding an else
clause allows you to handle different error cases specifically:
defmodule UserValidator do
def validate_user(params) do
with {:ok, username} <- extract_username(params),
{:ok, email} <- extract_email(params),
{:ok, _} <- validate_password(params) do
{:ok, %{username: username, email: email}}
else
{:error, :no_username} -> {:error, "Username is required"}
{:error, :no_email} -> {:error, "Email is required"}
{:error, :password_too_short} -> {:error, "Password must be at least 8 characters"}
{:error, :password_too_weak} -> {:error, "Password must include uppercase, lowercase and numbers"}
_ -> {:error, "Invalid user data"}
end
end
defp extract_username(%{username: username}) when is_binary(username) and username != "",
do: {:ok, username}
defp extract_username(_), do: {:error, :no_username}
defp extract_email(%{email: email}) when is_binary(email) and email != "",
do: {:ok, email}
defp extract_email(_), do: {:error, :no_email}
defp validate_password(%{password: password}) when is_binary(password) do
cond do
String.length(password) < 8 -> {:error, :password_too_short}
not (String.match?(password, ~r/[A-Z]/) and
String.match?(password, ~r/[a-z]/) and
String.match?(password, ~r/[0-9]/)) -> {:error, :password_too_weak}
true -> {:ok, password}
end
end
defp validate_password(_), do: {:error, :no_password}
end
IEx Test Session:
# Valid user data
iex> valid_user = %{username: "john_doe", email: "john@example.com", password: "Password123"}
%{username: "john_doe", email: "john@example.com", password: "Password123"}
iex> UserValidator.validate_user(valid_user)
{:ok, %{username: "john_doe", email: "john@example.com"}}
# Missing username
iex> no_username = %{email: "john@example.com", password: "Password123"}
%{email: "john@example.com", password: "Password123"}
iex> UserValidator.validate_user(no_username)
{:error, "Username is required"}
# Missing email
iex> no_email = %{username: "john_doe", password: "Password123"}
%{username: "john_doe", password: "Password123"}
iex> UserValidator.validate_user(no_email)
{:error, "Email is required"}
# Password too short
iex> short_password = %{username: "john_doe", email: "john@example.com", password: "Pass1"}
%{username: "john_doe", email: "john@example.com", password: "Pass1"}
iex> UserValidator.validate_user(short_password)
{:error, "Password must be at least 8 characters"}
# Password too weak (missing uppercase)
iex> weak_password = %{username: "john_doe", email: "john@example.com", password: "password123"}
%{username: "john_doe", email: "john@example.com", password: "password123"}
iex> UserValidator.validate_user(weak_password)
{:error, "Password must include uppercase, lowercase and numbers"}
# Completely invalid data
iex> invalid_data = "not even a map"
"not even a map"
iex> UserValidator.validate_user(invalid_data)
{:error, "Username is required"}
Pattern Matching in With
The with
expression combines particularly well with Elixir's pattern matching capabilities.
Destructuring Data
defmodule OrderProcessor do
def calculate_totals(order) do
with %{items: items, customer: customer} <- order,
%{discount_rate: discount_rate} <- customer,
{:ok, subtotal} <- calculate_subtotal(items),
{:ok, tax} <- calculate_tax(subtotal),
{:ok, discount} <- calculate_discount(subtotal, discount_rate) do
{:ok, %{
subtotal: subtotal,
tax: tax,
discount: discount,
total: subtotal + tax - discount
}}
else
nil -> {:error, :invalid_order}
%{} -> {:error, :missing_data}
{:error, reason} -> {:error, reason}
end
end
defp calculate_subtotal([]), do: {:error, :no_items}
defp calculate_subtotal(items) do
total = Enum.reduce(items, 0, fn %{price: price, quantity: quantity}, acc ->
acc + price * quantity
end)
{:ok, total}
end
defp calculate_tax(subtotal) when subtotal >= 0 do
{:ok, subtotal * 0.08} # 8% tax rate
end
defp calculate_tax(_), do: {:error, :invalid_subtotal}
defp calculate_discount(_, discount_rate) when discount_rate < 0 or discount_rate > 1 do
{:error, :invalid_discount_rate}
end
defp calculate_discount(subtotal, discount_rate) do
{:ok, subtotal * discount_rate}
end
end
IEx Test Session:
# Valid order
iex> valid_order = %{
items: [
%{name: "Product A", price: 100, quantity: 2},
%{name: "Product B", price: 50, quantity: 1}
],
customer: %{
name: "John Doe",
discount_rate: 0.1 # 10% discount
}
}
# Output
%{
items: [
%{name: "Product A", price: 100, quantity: 2},
%{name: "Product B", price: 50, quantity: 1}
],
customer: %{
name: "John Doe",
discount_rate: 0.1
}
}
iex> OrderProcessor.calculate_totals(valid_order)
{:ok, %{
subtotal: 250,
tax: 20.0,
discount: 25.0,
total: 245.0
}}
# Output
{:ok, %{total: 245.0, subtotal: 250, tax: 20.0, discount: 25.0}}
# Order with no items
iex> empty_order = %{
items: [],
customer: %{
name: "John Doe",
discount_rate: 0.1
}
}
# Output
%{
items: [],
customer: %{
name: "John Doe",
discount_rate: 0.1
}
}
iex> OrderProcessor.calculate_totals(empty_order)
# Output
{:error, :no_items}
# Order with invalid discount rate
iex> invalid_discount_order = %{
items: [
%{name: "Product A", price: 100, quantity: 2}
],
customer: %{
name: "John Doe",
discount_rate: 1.5 # Invalid: greater than 1
}
}
# Output
%{
items: [
%{name: "Product A", price: 100, quantity: 2}
],
customer: %{
name: "John Doe",
discount_rate: 1.5
}
}
iex> OrderProcessor.calculate_totals(invalid_discount_order)
{:error, :invalid_discount_rate}
# Invalid order format
iex> OrderProcessor.calculate_totals(nil)
# Output
{:error, :invalid_order}
# Missing customer discount rate
iex> no_discount_order = %{
items: [
%{name: "Product A", price: 100, quantity: 2}
],
customer: %{
name: "John Doe"
# Missing discount_rate
}
}
# Output
%{
items: [
%{name: "Product A", price: 100, quantity: 2}
],
customer: %{
name: "John Doe"
}
}
iex> OrderProcessor.calculate_totals(no_discount_order)
# Output
{:error, :missing_data}
Common Use Cases
API Request Handling
The with
expression is perfect for handling API requests with multiple validation and processing steps:
defmodule APIHandler do
def process_request(params) do
with {:ok, validated_params} <- validate_params(params),
{:ok, resource} <- find_resource(validated_params),
:ok <- check_permissions(resource, validated_params.user_id),
{:ok, updated_resource} <- update_resource(resource, validated_params.changes) do
{:ok, %{message: "Resource updated successfully", resource: updated_resource}}
else
{:error, :invalid_params} ->
{:error, 400, "Invalid parameters"}
{:error, :resource_not_found} ->
{:error, 404, "Resource not found"}
{:error, :unauthorized} ->
{:error, 403, "Permission denied"}
{:error, :update_failed} ->
{:error, 500, "Failed to update resource"}
error ->
{:error, 500, "Internal server error: #{inspect(error)}"}
end
end
# Sample implementations of the helper functions
defp validate_params(%{user_id: user_id, resource_id: resource_id, changes: changes})
when is_integer(user_id) and is_integer(resource_id) and is_map(changes) do
{:ok, %{user_id: user_id, resource_id: resource_id, changes: changes}}
end
defp validate_params(_), do: {:error, :invalid_params}
defp find_resource(%{resource_id: 404}), do: {:error, :resource_not_found}
defp find_resource(%{resource_id: id}) when is_integer(id), do: {:ok, %{id: id, name: "Resource #{id}", value: "Original value"}}
defp check_permissions(%{id: resource_id}, 999), do: {:error, :unauthorized}
defp check_permissions(_, _), do: :ok
defp update_resource(%{id: 500}, _), do: {:error, :update_failed}
defp update_resource(resource, changes), do: {:ok, Map.merge(resource, changes)}
end
IEx Test Session:
# Successful request
iex> valid_request = %{user_id: 123, resource_id: 456, changes: %{name: "Updated Name"}}
%{user_id: 123, resource_id: 456, changes: %{name: "Updated Name"}}
iex> APIHandler.process_request(valid_request)
{:ok, %{
message: "Resource updated successfully",
resource: %{id: 456, name: "Updated Name", value: "Original value"}
}}
# Invalid parameters
iex> invalid_params = %{user_id: "not_an_integer", resource_id: 456, changes: %{}}
%{user_id: "not_an_integer", resource_id: 456, changes: %{}}
iex> APIHandler.process_request(invalid_params)
{:error, 400, "Invalid parameters"}
# Resource not found
iex> not_found_request = %{user_id: 123, resource_id: 404, changes: %{}}
%{user_id: 123, resource_id: 404, changes: %{}}
iex> APIHandler.process_request(not_found_request)
{:error, 404, "Resource not found"}
# Unauthorized access
iex> unauthorized_request = %{user_id: 999, resource_id: 456, changes: %{}}
%{user_id: 999, resource_id: 456, changes: %{}}
iex> APIHandler.process_request(unauthorized_request)
{:error, 403, "Permission denied"}
# Update failure
iex> failing_update = %{user_id: 123, resource_id: 500, changes: %{}}
%{user_id: 123, resource_id: 500, changes: %{}}
iex> APIHandler.process_request(failing_update)
{:error, 500, "Failed to update resource"}
Data Pipeline Processing
The with
expression is also excellent for data processing pipelines:
defmodule DataProcessor do
def process_file(file_path) do
with {:ok, data} <- read_file(file_path),
{:ok, parsed_data} <- parse_data(data),
{:ok, transformed_data} <- transform_data(parsed_data),
{:ok, result} <- save_result(transformed_data) do
{:ok, %{message: "File processed successfully", records: length(parsed_data)}}
else
{:error, :file_not_found} ->
{:error, "File not found: #{file_path}"}
{:error, :invalid_format} ->
{:error, "Invalid file format"}
{:error, :transformation_error, details} ->
{:error, "Error during transformation: #{details}"}
{:error, :database_error, reason} ->
{:error, "Failed to save results: #{reason}"}
error ->
{:error, "Unexpected error: #{inspect(error)}"}
end
end
# Sample implementations
defp read_file("not_found.csv"), do: {:error, :file_not_found}
defp read_file("invalid.csv"), do: {:ok, "invalid,data,format"}
defp read_file("valid.csv"), do: {:ok, "id,name,value\n1,Item 1,100\n2,Item 2,200"}
defp read_file("empty.csv"), do: {:ok, "id,name,value"}
defp parse_data("invalid,data,format"), do: {:error, :invalid_format}
defp parse_data(data) do
[header | rows] = String.split(data, "\n")
headers = String.split(header, ",")
if rows == [] do
{:ok, []}
else
parsed_rows = Enum.map(rows, fn row ->
values = String.split(row, ",")
Enum.zip(headers, values) |> Enum.into(%{})
end)
{:ok, parsed_rows}
end
end
defp transform_data([]), do: {:ok, []}
defp transform_data(data) do
try do
transformed = Enum.map(data, fn row ->
# Convert the "value" field to an integer
Map.update(row, "value", "0", fn val ->
case Integer.parse(val) do
{num, _} -> num
:error -> raise "Invalid value: #{val}"
end
end)
end)
{:ok, transformed}
rescue
e -> {:error, :transformation_error, Exception.message(e)}
end
end
defp save_result(data) when length(data) > 100, do: {:error, :database_error, "too many records"}
defp save_result(data), do: {:ok, data}
end
IEx Test Session:
# Process a valid file
iex> DataProcessor.process_file("valid.csv")
{:ok, %{message: "File processed successfully", records: 2}}
# File not found
iex> DataProcessor.process_file("not_found.csv")
{:error, "File not found: not_found.csv"}
# Invalid format
iex> DataProcessor.process_file("invalid.csv")
{:error, "Invalid file format"}
# Empty file (no data rows)
iex> DataProcessor.process_file("empty.csv")
{:ok, %{message: "File processed successfully", records: 0}}
Best Practices
Replacing Nested Case and Conditionals
One of the most significant advantages of the with
structure is its ability to replace multiple nested case
expressions or conditionals, resulting in cleaner and more readable code.
Consider the following implementation using nested case
expressions:
def process_with_nested_case(params) do
case validate_input(params) do
{:ok, validated_data} ->
case fetch_resource(validated_data) do
{:ok, resource} ->
case update_resource(resource, validated_data) do
{:ok, updated_resource} ->
{:ok, updated_resource}
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
The same code using with
becomes much more concise and easier to understand:
def process_with_with(params) do
with {:ok, validated_data} <- validate_input(params),
{:ok, resource} <- fetch_resource(validated_data),
{:ok, updated_resource} <- update_resource(resource, validated_data) do
{:ok, updated_resource}
end
end
The code with with
has several advantages:
- Eliminates the progressive nesting that makes the code difficult to read
- Reduces repetition of error handling code
- Presents the "happy path" in a linear and clear way
- Implicitly propagates errors if there's no
else
clause
This approach is especially valuable in functional Elixir, where we transform data through a series of functions, each potentially returning an error that needs to be handled.
Keep With Expressions Flat
The with
special form is designed to keep code flat - avoid nesting another with
inside a with
expression. If you find yourself needing to nest, consider refactoring into separate functions.
# Instead of this:
def nested_with(params) do
with {:ok, a} <- step_one(params) do
with {:ok, b} <- step_two(a),
{:ok, c} <- step_three(b) do
{:ok, c}
end
end
end
# Do this:
def flat_with(params) do
with {:ok, a} <- step_one(params),
{:ok, b} <- step_two(a),
{:ok, c} <- step_three(b) do
{:ok, c}
end
end
# Or better, split into smaller functions:
def modular_approach(params) do
with {:ok, result} <- process_steps(params) do
format_result(result)
end
end
defp process_steps(params) do
with {:ok, a} <- step_one(params),
{:ok, b} <- step_two(a) do
step_three(b)
end
end
Use Guards for Additional Validation
Combine with
and guards for more powerful pattern matching:
defmodule NumberProcessor do
def process(input) do
with {number, _} <- Integer.parse(input),
true <- is_even?(number),
squared when squared < 100 <- number * number do
{:ok, squared}
else
:error -> {:error, "Not a valid integer"}
false -> {:error, "Number is not even"}
large_square -> {:error, "Square too large: #{large_square}"}
end
end
defp is_even?(number), do: rem(number, 2) == 0
end
IEx Test Session:
iex> NumberProcessor.process("8")
{:ok, 64}
iex> NumberProcessor.process("9")
{:error, "Number is not even"}
iex> NumberProcessor.process("12")
{:error, "Square too large: 144"}
iex> NumberProcessor.process("abc")
{:error, "Not a valid integer"}
Use with for Happy Path, Avoid Complex Else Clauses
The with
expression excels at expressing the "happy path" of your code. When error handling becomes complex, consider using function clauses or separate error-handling functions instead of cramming everything into the else
block.
# Instead of complex else clauses:
def complex_else(params) do
with {:ok, data} <- validate(params) do
process(data)
else
{:error, :reason1} -> handle_reason_1()
{:error, :reason2} -> handle_reason_2()
# ... many more error cases
{:error, reason} -> handle_generic_error(reason)
_ -> handle_unexpected_error()
end
end
# Prefer pattern matching in function clauses:
def simpler_approach({:ok, data}), do: process(data)
def simpler_approach({:error, :reason1}), do: handle_reason_1()
def simpler_approach({:error, :reason2}), do: handle_reason_2()
def simpler_approach({:error, reason}), do: handle_generic_error(reason)
def simpler_approach(_), do: handle_unexpected_error()
Avoiding Unnecessary Pattern Matching
Use regular assignments (=
) instead of pattern matching (<-
) when failure is not expected:
# Use <- only when pattern matching could fail
with {:ok, data} <- fallible_operation(),
result = calculate(data), # Regular assignment - cannot fail
formatted = format(result) do # Regular assignment - cannot fail
{:ok, formatted}
end
Conclusion
The with
special form in Elixir elegantly solves the problem of handling sequences of operations where each depends on the success of the previous one. By combining pattern matching, sequential binding, and centralized error handling, with
enables cleaner, more maintainable code with less nesting and better error handling.
When to use with
:
- When you have a sequence of dependent operations
- When you need to handle errors in a consistent way
- When you want to avoid deep nesting
- When pattern matching is central to your logic
Remember that with
is just one tool in Elixir's control flow toolbox. Use it alongside pattern matching in function heads, case
, and cond
to create expressive, maintainable code.
Tip: The
with
expression shines when describing a series of steps that should all succeed or fail as a unit. Think of it as a way to express a "transaction" of operations at the code level.
Further Reading
Next Steps
In the next article, we'll explore guards in greater depth:
Guards in Depth
- Advanced pattern matching with guards
- Custom guard expressions
- Guard limitations and workarounds
- Best practices for complex guard clauses
Top comments (0)