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.