Stack Builders logo
Arrow icon Insights

Brewing a Safer Elixir

Leverage Elixir's core features in order to prevent unexpected errors at runtime.

Elixir is a dynamic, functional language that works on top of the Erlang Virtual Machine. It aims for reliable, performant and scalable applications.

In this post, we're going to focus on the following topics related to this language:

  • Functional programming: a programming paradigm that allows developers to create code that is short and concise by using features like guards or pattern matching.
  • Dynamic typing: a characteristic of a programming language that evaluates types at runtime.

You may be wondering: How is Elixir going to help me to build a reliable and scalable application when it's a dynamic language? I could have a type error at any moment. Don't worry, we are going to solve this question. But first, we need to talk about some tools and concepts.

Mix

Mix is Elixir's build tool that allows us to create projects, manage dependencies, run tasks and even more. Mix can also compile your project's dependencies and source code with the following commands:

mix deps.compile && mix compile

Now that we know how to compile our code, let's create a small module with only one function:

defmodule MyModule do
  def greet(pet), do: "Hey #{pet}!"
end

Pretty straightforward, no compile errors and it works as expected. However on a daily basis (and after a few dozen or hundreds of lines of code) there's always the possibility to make a typo leading our code to runtime errors.

Almost all (if not all) developers with experience in web development have run into a reference error, usually written as "yourVariable" is not defined. This is something common in dynamic languages and of course, there are ways to prevent these errors (TypeScript instead of Vanilla JavaScript for instance).

Lucky for us, compiling Elixir already helps us to prevent this problem out of the box. Let's introduce a typo in our previous example:

defmodule MyModule do
  def greet(not_used_variable), do: "Hey #{pet}!"
end

When compiling the project again, it would display the following output:

mix compile
Compiling 1 file (.ex)
warning: variable "pet" does not exist and is being expanded to "pet()", please use parentheses to remove the ambiguity or change the variable pet
  lib/barebones.ex:2: MyModule.hello/1

warning: variable "not_used_variable" is unused (if the variable is not meant to be used, prefix it with an underscore)
  lib/barebones.ex:2: MyModule.hello/1

== Compilation error in file lib/barebones.ex ==
** (CompileError) lib/barebones.ex:2: undefined function pet/0

With these messages, we already can see some benefits from the compiler:

  • It fails compilation when there's a typo.
  • Warns us for unused variables, thus guarding against dead code. This can be achieved by using the command line option --warnings-as-errors.
mix compile --warnings-as-errors

Or if you would like to enable that by default for a project, you can add that option under elixirc_options in your project's configuration.

defmodule Example.MixProject do
  use Mix.Project

  def project do
    [
      app: :example,
      version: "0.1.0",
      elixir: "~> 1.10",
      start_permanent: Mix.env() == :prod,
      elixirc_options: [warnings_as_errors: true]
    ]
  end
end

By using this option, it will make the compilation fail in case there are any warnings in your code.

Pattern Matching and Guards

Pattern matching allows us to define patterns to check how data is conformed. Furthermore, when writing functions, you can define different bodies for different patterns and as a result, write cleaner and more readable code.

In Elixir, you can use pattern matching everywhere, even in function declarations. Let's update our example with a simple match.

defmodule MyModule do
  def greet("dog"), do: "Woof woof!"

  def greet(pet), do: "Hey #{pet}, we haven't meet before!"
end

Now our function has two possible execution paths based on the argument it receives. But, this still has a flaw. What would happen if the given argument is not a string? This is better known in languages like Haskell as a partial function or in other words, a function that doesn't take into account all of the possible execution paths that an argument may have.

We need to remember that Elixir is a dynamic language, and an argument can have any type during runtime. Since we don't have type-checking during compilation, we may want to add more complex checks in our function and that's where guards come in. Let's add a guard to our example so it will format a message when the greet function receives only strings.

defmodule MyModule do
  def greet("dog"), do: "Woof woof!"

  def greet(pet) when is_binary(pet), do: "Hey #{pet}, we haven't meet before!"

  def greet(_), do: "Oops! That's not a string"
end

By using the when keyword followed by a boolean expression, we can make sure our function only works when the resulting expression is true. is_binary is a wrapper for Erlang's function is_binary which returns true if the given argument is a string. With both things together, this means the following: only execute this function when the argument is a string.

But that's not all. If we would like to have a more extensible API, we could also provide specific optional patterns by using keywords or maps. Let's update our example accordingly.

defmodule MyModule do
  def greet("dog"), do: "Woof woof!"

  def greet(pet) when is_binary(pet), do: "Hey #{pet}, we haven't meet before!"

  # Match a keyword list which contains `:pet` string element
  def greet(pet: pet) when is_binary(pet), do: greet(pet)

  # Match a map which the `:pet` key
  def greet(%{pet: pet}) when is_binary(pet), do: greet(pet)

  def greet(_), do: "Oops! That's not a string"
end

With this, our greet function will work properly for any given string in different data-types and it also has a fallback message in case the given argument is not valid. And voilá! We now have a total function that covers all paths and would lead to no runtime errors!

Structs

Now that we have used pattern matching and guards, we probably would like to add extra safety to our function. We can do so by using Elixir's structs. But first of all, let's understand what they are, as stated on their site:

Structs are extensions built on top of maps that provide compile-time checks and default values.

Just what we were looking for, more compile-time checks! Let's add a simple Pet struct to our example.

defmodule MyModule do
  defmodule Pet do
    defstruct [:kind]
  end

  def greet("dog"), do: "Woof woof!"

  def greet(pet) when is_binary(pet), do: "Hey #{pet}, we haven't meet before!"

  @doc "Match a keyword list which contains `:pet` string element"
  def greet(pet: pet) when is_binary(pet), do: greet(pet)

  @doc "Match a map which the `:pet` key"
  def greet(%{pet: pet}) when is_binary(pet), do: greet(pet)

  def greet(%Pet{origin: kind}) when is_binary(kind), do: greet(kind)

  def greet(_), do: "Oops! That's not a string"
end

By doing those changes in our example and after compiling it, the following messages would be displayed in our console:

Compiling 1 file (.ex)

== Compilation error in file lib/barebones.ex ==
** (CompileError) lib/barebones.ex:14: unknown key :origin for struct MyModule.Pet
    lib/barebones.ex:14: (module)

The compilation error message is really helpful. It says that we have a typo in our code while doing pattern matching in the Pet struct. It will work properly after renaming origin to kind.

Going beyond

For increasing security and reliability of our code, we could introduce some typespecs but besides giving our code the ability to check for contracts, this can also help with general documentation and readability of it.

defmodule MyModule do
    defmodule Pet do
    @type t ::  %Pet{kind: String.t()}
    defstruct [:kind]
  end

  @spec greet(String.t() | [pet: String.t()] | %{pet: String.t() | Pet.t()}) :: String.t()
  def greet("dog"), do: "Woof woof!"

  def greet(pet) when is_binary(pet), do: "Hey #{pet}, we haven't meet before!"

  # Match a keyword list which contains `:pet` string element
  def greet(pet: pet) when is_binary(pet), do: greet(pet)

  # Match a map which the `:pet` key
  def greet(%{pet: pet}) when is_binary(pet), do: greet(pet)

  def greet(%Pet{kind: kind}) when is_binary(kind), do: greet(kind)
end

It is worth noting that we removed the last scenario of our greet function so the typespec fulfills all the different arguments that the function will receive.

Furthermore in September 2023, gradual types were announced which will give us more tools to increase the reliability of our code.

Now that we have a deeper understanding of pattern matching, guards, structs and typespecs, we have a better idea of how to prevent runtime errors in Elixir and also how to receive proper compile-time checks and together with the usage of Dyalizer, we can check for the correctness of typespecs in our code.

Pattern matching structs allows you to be more expressive on your business objects in any project. A great example of this would be the database DSL Ecto. Under the hood, it creates structs over your database entities and that makes it easier to perform pattern matching against them.

Minor caveats

Using structs in your codebase will help you get better compile-time checks, but, as always there's a drawback. Each time we use a struct for pattern matching we create a dependency in our module thus leading to greater compilation times.

This is not bad on its own but it could possibly affect a developer's programming cycle. Taking more time to compile then to execute code after making changes is a frequent task and in big projects, it could even take up to a few minutes to compile everything again (in the worst case scenario).

Closing remarks

By using Elixir's core features we will be able to introduce more safety to our code. But it's not only about knowing and using its core features. It also means we need to change our mindset.

Having a technical background is the first step towards a functional mindset. After that we need to apply those concepts on a daily basis to make using them a habit, allowing us to understand their benefits like making our code declarative, re-usable and more. But it doesn't mean we only have to limit this mindset to when we're using Elixir. We can also apply these concepts to any other programming language.

At Stack Builders, we encourage the use of functional programming. By applying this mindset, we are able to deliver high-quality code to our customers. If you are interested in functional programming, make sure to check out out our other programming blogs.

Published on: Dec. 1, 2020
Last updated: Dec. 20, 2024

Written by:

Esteban
Esteban Ibarra

Subscribe to our blog

Join our community and get the latest articles, tips, and insights delivered straight to your inbox. Don’t miss it – subscribe now and be part of the conversation!
We care about your data. Check out our Privacy Policy.