DEV Community

Cover image for Learning Elixir: Control Flow with With
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Control Flow with With

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

In our previous articles, we explored various control flow structures in Elixir:

  • if and unless 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

In this example:

  1. {:ok, number} matches {:ok, 42}, binding number to 42
  2. We calculate double as number * 2 (84)
  3. {:ok, squared} matches {:ok, double * double}, binding squared to 7056
  4. 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}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)