Better Control Flow and Error Messages using With-Else in Elixir

Today I want to go over Elixir’s with-else control flow. I find this a very powerful tool for chaining together sequences of functions that depend on the previous result but should return different error states at each step. It solves the same problem that nested if statements would, but in a much more elegant way. Combined with helper functions and using :ok or :error tuples, we can break up a multi step process into easy to follow, well tested code.

Let’s take a look at our example. We are parsing a string with job id and a datetime, we want to validate our input, validate the job ID, validate the datetime string, and if all goes well check if the time is in the future. If any of the validations fail we want to return a specific error messaage, and have a fallback error for any unexpected cases. We’ve found this particularly useful when dealing with external APIs that change regularly or provide unexpected inputs. You can’t code for every bad input so it’s often simpler to log it and then deal with it as it arises.

A valid jid has the following structure: "jid-123-456-789@2122-11-12T11:05:25+06:00"

We want to define a function to do the following:

  1. Make sure input is valid
  2. Make sure jid is valid
  3. Make sure datetime is valid
  4. Check if datetime is in future

If we were to pseudocode this using if else, it would look something like this:

input = validate_input(string)
if input_valid?(input) do
  jid = get_jid(input)
  if valid_jid?(jid) do
    parsed_time = parse_time(input)
    if valid_time(parsed_time) do
      result = check_time(parsed_time)
      if in_future?(parsed_time) do
        {:ok, true}
      else
        {:ok, false}
      end
    else
      {:error, :invalid_format}
    end
  else
    {:error, "The input does not have a valid job_id"}
  end
else
  {:error, "The input does not follow the required format"}
end

We could definitely clean this up using case statements, but the ideal way to represent this control flow is using with-else. Let’s lay out the overall structure of our function using with-else:

def in_future?(time_string) do
  with {:ok, jid, time} <- extract_elements(time_string),
    {:ok, _valid_jid} <- validate_jid(jid),
    {:ok, parsed_time, _} <- DateTime.from_iso8601(time),
    {:ok, result} <- check_time(parsed_time) do
    result
  else
    {:error, msg} -> {:error, msg}
    _ -> {:error, "Fallback error"}
  end
end

And the helper functions are below:

  defp extract_elements(time_string) do
    case String.split(time_string, "@") do
      [jid, time] -> {:ok, jid, time}
      _ -> {:error, "The input does not follow the required format"}
    end
  end

  defp validate_jid(jid) do
    if Regex.match?(~r/^jid-[0-9]{3}-[0-9]{3}-[0-9]{3}$/, jid) do
      {:ok, jid}
    else
      {:error, "The input does not have a valid job_id"}
    end
  end

  defp check_time(time) do
    case DateTime.compare(DateTime.utc_now(), time) do
      :lt -> {:ok, true}
      _ -> {:ok, false}
    end
  end

As you can see, we are able to represent a control flow that would normally be deeply nested if else statement with an easy to follow with else that returns appropriate error messages.

We cover how to set this up with tests in this video.

Sign up for our newsletter

Get notified of any new episodes as we release them.

© 2020 QuantLayer, LLC. All rights reserved.