Clivern

A Software Engineer and Occasional Writer.

Early Returns in Elixir

07 June 2024

Early returns is often employed to exit a function as soon as an exceptional or negative condition is met. It won’t take much time when learning elixir to figure out that early returns are missing!

In Elixir, the concept of “early returns” is not present as it is in some other programming languages. Elixir functions are designed to return the value of the last expression evaluated within the function body.

However, Elixir provides a functional programming approach that allows you to achieve similar behavior through pattern matching and control flow constructs. We will go through them

Pattern Matching

Pattern matching allows functions to have multiple clauses, each one is handling different kind of inputs. this achieves early return by immediately matching and handling specific conditions. It is similar to method overloading in OOP languages

defmodule Example do
    def find_element([], _), do: false
    def find_element([head | _], target) when head == target, do: true
    def find_element([_ | tail], target), do: find_element(tail, target)
end

IO.puts Example.find_element([], 20)           # Output: false
IO.puts Example.find_element([10, 20, 30], 20) # Output: true
IO.puts Example.find_element([20, 30, 40], 20) # Output: true
IO.puts Example.find_element([10, 20], 20)     # Output: true
IO.puts Example.find_element([30, 40, 50], 20) # Output: false

Well if the above code seems hard to understand, here is the naive implementation

defmodule Example do
  def find_element(lst, target) do
    if length(lst) == 0 do
      false
    else
      [head | tail] = lst

      if head == target do
        true
      else
        find_element(tail, target)
      end
    end
  end
end

IO.puts Example.find_element([], 20)           # Output: false
IO.puts Example.find_element([10, 20, 30], 20) # Output: true
IO.puts Example.find_element([20, 30, 40], 20) # Output: true
IO.puts Example.find_element([10, 20], 20)     # Output: true
IO.puts Example.find_element([30, 40, 50], 20) # Output: false

Here is also how I would do it in python since it doesn’t support pattern matching

def find_element(lst, target):
    if not lst:
        return False
    elif lst[0] == target:
        return True
    else:
        return find_element(lst[1:], target)

print(find_element([1, 2, 3, 4], 3))  # Output: True
print(find_element([1, 2, 3, 4], 5))  # Output: False

Guard Clauses

Guard Clauses are used in functions to specifiy additional conditons that must be met for the function to be executed. Imagine that some functions needs only positive values to run so you better do it with guard clauses

def process_value(value) when value < 0, do: {:error, "Negative value"}
def process_value(value), do: {:ok, value * 2}

or something like that

def is_negative_int?(x) when x < 0, do: true
def is_negative_int?(_), do: false

case and cond Statement

case and cond statements provide a way to handle multiple conditions within a function. It can do some pattern matching for the value used in case statement. here is few examples.

defmodule Example do
  def create_entity(params \\ %{}) do
    result = {:ok, %{ID: 1}}
    case result do
      {:ok, %{ID: id}} -> IO.puts "Entity with id #{id} created"
      {:error, message} -> IO.puts "Error #{message} raised"
    end
  end
end

IO.puts Example.create_entity(%{}) # Output: Entity with id 1 created

with Statement

The with statement is used for chaining multiple operations that may fail, I used this one alot in input validation within phoenix framework. When i started doing elixir i was using throw and catch statement alot for input validation but then I switched fully to use with statement. Here is an example.

defmodule Example do
  def validate_inputs(inputs) do
    with {:ok, _} <- is_string?("Username", inputs.name),
         {:ok, _} <- is_not_empty?("Username", inputs.name),
         {:ok, _} <- is_between?("Username", inputs.name, 3, 60) do
      {:ok, inputs}
    else
      {:error, reason} -> {:error, reason}
    end
  end

  defp is_string?(name, value) do
    if is_binary(value) do
      {:ok, value}
    else
      {:error, "#{name} is not a valid string"}
    end
  end

  defp is_not_empty?(name, value) do
    value = String.trim(value)

    if String.length(value) == 0 do
      {:error, "#{name} can't be empty"}
    else
      {:ok, value}
    end
  end

  defp is_between?(name, value, min, max) do
    value = String.trim(value)

    if String.length(value) >= min and String.length(value) <= max do
      {:ok, value}
    else
      {:error, "#{name} must be between #{min} and #{max}"}
    end
  end
end

IO.inspect Example.validate_inputs(%{name: "Joe"}) // {:ok, %{name: "Joe"}}
IO.inspect Example.validate_inputs(%{name: ""})    // {:error, "Username can't be empty"}
IO.inspect Example.validate_inputs(%{name: "  J"}) // {:error, "Username must be between 3 and 60"}

throw and catch Statement

Elixir supports also throw and catch for early exits, which can be used to implement early return-like behavior. I used to do the above example like the following to get the return-like behavior.

defmodule Example do
  def validate_inputs(inputs) do
    try do
      if not is_binary(inputs.name) do
        throw({:error, "Name must be a string"})
      end

      if String.length(inputs.name) == 0 do
        throw({:error, "Name must not be empty"})
      end

      if String.length(inputs.name) > 60 do
        throw({:error, "Name must not exceed 60 characters"})
      end

      {:ok, inputs}
    catch
      {:error, message} -> {:error, message}
    end
  end
end

IO.inspect Example.validate_inputs(%{name: "Joe"}) # {:ok, %{name: "Joe"}}
IO.inspect Example.validate_inputs(%{name: ""}) # {:error, "Name must not be empty"}

I figured out that it’s not idiomatic in Elixir to use throw and catch for this kind of validation. Typically, pattern matching and guard clauses are preferred. However, if you want to stick with throw and catch, the implementation is mostly correct.

In elixir throw and catch constructs used to raise unexpected errors or exceptional situations like system crashes or divide by zero these sort of things.