Elixir : Basics of errors and error handling constructs

Arunmuthuram M
7 min readJan 25, 2024

--

Errors are mechanisms used to indicate when something abnormal or unexpected happens in your code. Errors can happen during the compilation of code, commonly called as compile time errors and during the execution of code in runtime, called as runtime errors. Like every other language, Elixir provides different constructs that can be used to handle and deal with runtime errors.

Errors

Runtime errors when raised, stop the execution of code and terminate the process inside which the error was raised. A runtime error in Elixir contains information such as error type, message and stack trace information.

iex> 6/0
** (ArithmeticError) bad argument in arithmetic expression: 6 / 0
:erlang./(6, 0)
iex:6: (file)

Almost all of the Elixir’s built-in functions have two variations. One whose function names end with a ! that raises a runtime error, and another that returns a tuple with error information. When to use what depends on whether you can handle the error using code. For scenarios where the errors are unexpected, non-recoverable or if they cannot be handled using code, then the most suitable way to handle it is by letting it crash by raising an error, so that the process associated with the error can be restarted to heal and serve again. For e.g. if the whole process’s functionality depends on reading a file and if that file does not exist or is corrupted, then there is only less you can do with your code. In this case, letting the process crash and having some form of alert notification is the better option.

On the other hand, for errors which are expected, recoverable, part of the function logic and can be handled using code, then the result tuples are more suitable. For e.g. when you are processing data sent by users and if the data is of invalid format, then the right thing to do is use tuples to propagate information about the invalid format of the input data back to the user, so that the process can work on other requests, instead of shutting down the whole process by raising an error. Multiple conditional flow constructs such as case, with, if/else etc can be used to handle code flow related to result tuples.

def init_process() do
contents = File.read!("important_file.txt") # raises error
process_contents(contents)
end

---------------------------------------------------------------------------

def process_input(input) do
with {:ok, id} <- validate_name(input), # returns tuple with result info
{:ok, name} <- validate_name(input) do
process(%User{id: id, name: name})
else
{:error, err_msg} -> process_error(err_msg)
end
end

raise

Runtime errors can be raised manually through code by using the raise/1 and raise/2 macro. The raise/1 macro takes in either a string message that will be raised as a RuntimeError, a valid exception struct or an atom exception type for which its default message will be used.

raise "error message"
** (RuntimeError) error message
iex:10: (file)

raise %ArgumentError{message: "Invalid arguments"}
** (ArgumentError) Invalid arguments
iex:10: (file)

raise ArgumentError # uses default message
** (ArgumentError) argument error
iex:10: (file)

The raise/2 macro takes in the atom error type as its first argument and a keyword list containing attributes or a message string as its second argument. The keyword list attributes or the message string will internally be used to construct the exception struct, from which the error message will in turn be constructed from.

raise(ArgumentError, "Invalid arguments")
** (ArgumentError) Invalid arguments
iex:10: (file)

raise(URI.Error, [action: "parse", reason: "invalid URI", part: "?_ a"])
** (URI.Error) cannot parse due to reason invalid URI: "?_ a"
iex:10: (file)

Creating a custom error

Internally all errors in Elixir are based on structs created from modules. An error module must implement the Exception behaviour and its required callbacks in order to be a valid error. Hence custom errors can also be created by defining error modules and implementing the Exception behaviour. Alternatively you can also use the defexception macro that abstracts away the implementation of the Exception behaviour and injects default implementations for required callbacks.

defmodule CustomError do
defexception [message: "Default custom error"]
end
---------------------------------------------------------------------------
raise CustomError
** (CustomError) Default custom error
iex:13: (file)

raise(%CustomError{message: "New error message"})
** (CustomError) New error message
iex:13: (file)

raise(CustomError, "New error message")
** (CustomError) New error message
iex:13: (file)

raise(CustomError, [message: "New error message"])
** (CustomError) New error message
iex:13: (file)

Internally, the defexception macro will expand and inject code during compilation to roughly create something like this.

defmodule CustomError do
@behaviour Exception

defstruct [__exception__: true, message: "Default custom error"]

@impl true
def message(exception) do
exception.message
end

@impl true
def exception(msg) when is_binary(msg) do
exception(message: msg)
end

@impl true
def exception(args) when is_list(args) do
Kernel.struct!(struct, args)
end
end

Both the exception/1 and message/1 callbacks can be overridden by the implementer. The arguments passed into the raise macro will first be passed into the exception/1 callback to obtain the exception struct. The exception struct will then be passed into the message/1 callback to obtain the error message which will be used in the raised error. Hence if you are using additional attributes in your error struct to construct the message, then the message/1 callback must be overridden.

defmodule CustomError do
defexception [action: "default action", reason: "default reason"]

@impl true
def message(%CustomError{action: action, reason: reason}) do
"Error performing action: #{action}, due to reason: #{reason}"
end
end
---------------------------------------------------------------------------
raise(CustomError, action: :read_file, reason: :invalid_format)
** (CustomError) Error performing action: read_file, due to reason: invalid_format
iex:51: (file)

The Exception module also consists of functions that can be used to format messages, stacktrace, add more information to an error etc.

try/rescue

Errors raised from code present inside the try block can be caught and handled using the rescue block. When an error is raised, the exception struct of the error will be passed on to the rescue block. It can be pattern matched against error types using multiple patterns and the code block matching the pattern will be executed. If none of the patterns match the error, then the raised error will not be caught and the execution and the process associated with it will be terminated.

defmodule Test do
def test(a, b, c) do
try do
val = (a/b) + :math.sqrt(c)
if val < 0, do: raise("Negative result"), else: IO.puts({val})
rescue
ArithmeticError -> IO.puts("Divide by zero error")
ArgumentError -> IO.puts("Inputs not valid numbers")
err in RuntimeError -> IO.inspect(err)
err -> IO.inspect(err, label: "Unknown error")
end
end
end
---------------------------------------------------------------------------
Test.test(3, 0, 3)
Divide by zero error

Test.test(3, 3, nil)
Inputs not valid numbers

Test.test(-10, 2, 2)
%RuntimeError{message: "Negative result"}

Test.test(10, 2, 2)
Unknown error: %Protocol.UndefinedError{
protocol: String.Chars,
value: {6.414213562373095},
description: ""
}

Rescue blocks are mostly used for processing an error such as logging it, sending it somewhere for analytics and monitoring etc. Once processing is done for the error, it can be reraised again along with the stacktrace information using the reraise macro, so that the process will be terminated and restarted anew by the supervisor. The exception struct matched inside the rescue block does not contain any stacktrace information and instead, it can be accessed inside the rescue block using the __STACKTRACE__ macro.

try do
contents = File.read!("important_file.txt")
CustomProcessor.process_contents(contents)
rescue
err -> CustomErrorMonitoringService.log(err, __STACKTRACE__)
reraise(err, __STACKTRACE__)
end

When using the try block inside a function, the try keyword need not be used explicitly if another block such as rescue is used after it. The whole body of the function will be implicitly wrapped inside a try block in such cases.

defmodule Test do
def test do
contents = File.read!("important_file.txt")
CustomProcessor.process_contents(contents)
rescue
_ -> IO.puts("Error reading file")
end
end

throw/catch

The throw function call is used to halt the function execution and throw a value from within the try block, which is then caught inside the catch block. The throw/catch syntax is mainly used in constructs where there is no support for halting the execution and returning a value when a condition has been met.

input_bin = <<1,2,3,4,5,6,7,8,9.............,250,0,1,2,3,4,5,6,7,........>>

try do
for <<x <- input_bin>>, reduce: <<>> do
acc -> if x == 0, do: throw(acc), else: <<acc::bytes, x, x>>
end
catch
result -> result
end

<<1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,..........,250,250>>

In the above code, since the for comprehension does not support halting the execution while iteration, the throw function is used to return the accumulated binary as soon as the iteration encounters a 0 byte, instead of iterating the rest of the unwanted bytes. They can also be used in time-sensitive functions for cleanly exiting from a deeply nested recursion instead of propagating results all the way up the stack to the outermost function call. Similar to the rescue block, stacktrace information is also available inside the catch block via the __STACKTRACE__ macro.

The catch block can also be used to catch process exits and stop the process from crashing.

exit(:test)
** (exit) :test
iex:239: (file)
---------------------------------------------------------------------------
try do
exit(:test)
catch
:exit, reason -> IO.puts("Caught process exit :: #{reason}")
end
Caught process exit :: test

else

The else block can be used along with the try/rescue and try/catch code blocks. If there was no error raised or no value thrown from inside the try block, then the result of the last expression of the try block will be passed onto the else block, where it can be pattern matched and the resulting code block can be executed.

defmodule Test do
def test(a, b) do
try do
a/b
rescue
_ -> IO.puts("Error in division")
else
x -> IO.puts("Result: #{x}")
end
end
end
---------------------------------------------------------------------------
Test.test(3,0)
Error in division

Test.test(3,3)
Result: 1.0

after

The after block can be used at the end of a try block to execute clean up code irrespective of whether an error was raised, a value was thrown or the code in the try block gets executed successfully. It is mostly used to close resources that were opened and to perform other clean up operations.

try do
raise "error"
rescue
_ -> IO.puts("Error")
after
IO.puts("cleanup")
end

Error
cleanup
---------------------------------------------------------------------------

try do
1 + 1
rescue
_ -> IO.puts("Error")
after
IO.puts("cleanup")
end

cleanup
2

Similar to other constructs in Elixir, the variables defined inside any of the blocks mentioned above will not be accessible outside the block.

--

--