Poyters
22 min read

Elixir Flashcards - functional fundations

1. Bases (functional programming, BEAM and Erlang)

1.1 What kind of language is Elixir?

Elixir is a dynamic, functional language built for creating scalable and fault-tolerant applications. It runs on the Erlang Virtual Machine BEAM and is designed to leverage its strengths in concurrency, distribution, and fault tolerance.

1.2 What happens when you compile Elixir code?

Elixir code is compiled to BEAM bytecode - the same format used by Erlang. These compiled files have the .beam extension and are executed by the BEAM virtual machine.

1.3 Does Elixir run on a different runtime than Erlang?

No. Elixir and Erlang run on the exact same runtime, the BEAM. Elixir is not a reimplementation of Erlang - it’s a different language that targets the same VM.

1.4 Can Elixir and Erlang code interoperate?

You can call Erlang functions from Elixir and vice versa, import Erlang standard libraries, and even include .erl files in Elixir projects.

# Calling the Erlang math module from Elixir

:math.sqrt(9) # => 3.0
% Calling a custom Erlang module from Elixir

-module(greeting).
-export([hello/0]).

hello() ->
    io:format("Hello from Erlang!~n").

% You can compile it:
erlc greeting.erl

1.5 Is Elixir slower than Erlang?

In practice, Elixir performs just as well as Erlang, since both compile to the same BEAM bytecode. Performance differences are negligible, and mostly depend on how you write your code - not the language itself.

1.6 What makes the runtime special?

Elixir runs on the BEAM - the same virtual machine that powers Erlang systems, many of which have been running 24/7 for decades (e.g. telecom switches, banking, messaging platforms).

The BEAM was designed from the ground up to support five key pillars:

1.6.1 Fault tolerance

Erlang (base language of Elixir) processes are completly isolated from each other. If one crashes, it doesn’t affect the rest. Supervision trees let the system recover gracefully - let it crash is a feature, not a bug.

1.6.2 Responsiveness

The VM uses preemptive scheduling to ensure that no process can block others. Even under heavy load, the system stays responsive.

1.6.3 Distribution

BEAM has built-in support for connecting multiple nodes across machines. Processes can send messages to each other - locally or over the network - without extra libraries or complexity.

1.6.4 Scalability

Because BEAM processes are lightweight and isolated, you can run hundreds of thousands (even millions) of them efficiently, across multiple cores or machines.

1.6.5 Hot code upgrades

The BEAM supports live updates. You can deploy changes without restarting the system, which is critical for telecom and banking environments. You wouldn’t want to disconnect established calls while you upgrade the software.

1.7 Data immutability and memory management

In Elixir, data is always immutable.

If you want to “change” something, you’re really just creating a new version of it. That might seem wasteful at first, but it actually works really well with how Elixir handles memory.

Every process has its own little memory space and its own garbage collector, so cleaning up temporary data is fast and doesn’t slow down the rest of the system. Thanks to this setup, plus super lightweight processes, you get safe, predictable concurrency without eating up tons of memory. Even when your app is doing a million things at once.


2. Elixir building blocks

2.1 Everything is a value

One of the fundamental principles of Elixir (and functional programming in general) is that everything returns a value.

There are no void functions or statements that exist purely for side effects. Whether you’re using a control structure like if, a function like String.upcase/1, or a custom pipeline of transformations, each operation produces a result.

This design makes function composition not just possible, but natural - because you’re always working with return values.

For instance:

result =
  if true do
    "yes"
  else
    "no"
  end

IO.puts(result)

# => prints: yes

Even if is an expression. It evaluates to a value based on the condition, which can be passed along, assigned, or returned.

This consistency makes Elixir highly chainable, composable, and predictable - ideal for building data pipelines and transforming values step by step.

2.2 Functions

2.2.1 Composition

Elixir provides an elegant way to compose functions using the pipe operator: |>. It allows you to chaing multiple functions calls together in a clear and readeble way - passing the result of one function as the first argument to the next.

"   hello world   "
  |> String.trim() # removes the leading and trailing spaces → "hello world"
  |> String.upcase() # converts it to "HELLO WORLD
  |> String.split() # splits it into ["HELLO", "WORLD"]

Without pipe:

String.split(String.upcase(String.trim("  hello world  ")))

2.2.2 Function arity

In Elixir, arity refers to the number of arguments a function takes. It’s an important concept because functions with the same name but different arity are treated as completely separate functions.

defmodule Greet do
  def hello(), do: "Hello!"
  def hello(name), do: "Hello, #{name}!"
end

In this case, hello/0 and hello/1 are two different functions:

2.2.3 Shorthands

In Elixir, you can write simple one-liner functions using the shorthand do: syntax. It is clean and great for small, focused logic.

defmodule Math do
  def square(x), do: x * x
  def double(x), do: x * 2
end

It’s functionally the same as using do ... end, just shorter - perfect when the body is a single expression.

Also, you can reference a function using &module.function/arity, which gives you an anonymous version of that function.

adder = &Math.add/2
adder.(2, 3) # => 5

You can also use the & shorthand to create anonymous functions with placeholders like &1, &2, and so on.

Enum.map([1, 2, 3], &(&1 * 2)) # => [2, 4, 6]

2.3 Modules

In Elixir, a module is just a way to group related functions together under a single name. It helps keep your code organized and makes it easy to reuse logic.

You define a module using defmodule, and then write your functions inside it with def.

defmodule User do
  # Define a struct with default fields
  defstruct [:name, :age]

# Public function to create a new user from string-keyed mapW

def new(attrs) do
attrs
|> normalize_keys()
|> build_struct()
end

# Public function to check if user is an adult

def adult?(%User{age: age}) do
age >= 18
end

# Private helper to clean up external input

defp normalize_keys(%{"name" => name, "age" => age}) do
%{name: name, age: age}
end

# Private helper to turn a map into a User struct

defp build_struct(attrs) do
struct(**MODULE**, attrs)
end

# You can nest module definitions

defmodule Formatter do
def greeting(%User{name: name}) do
"Hello, #{name}!"
end

    def label(%User{name: name, age: age}) do
      "#{name} (#{age} years old)"
    end

end
end

# Usage:

user = User.new(%{"name" => "Alice", "age" => 25})
User.adult?(user) # => true
User.Formatter.greeting(user) # => "Hello, Alice!"
User.Formatter.label(user) # => "Alice (25 years old)"

The latter is often used to organize modules hierarchically:

defmodule Bmt.Analytics.InteractionEvent do
  # Code...
end

defmodule Bmt.Analytics.EventLog do
  # Code...
end

You can’t split a module across files - it has to live in one. But you’re free to define more than one module in the same file.

2.4 Numbers

Numbers can be integers or floats. Numbers work mostly as you expected, so I’m gonna focus only on some untypical:

2.5 Atoms

In Elixir, atoms are constants whose name is their own value. They’re often used to represent fixed, named values like flags, tags, or states - similar to enums or symbols in other languages.

Elixir doesn’t have a dedicated boolean type. Instead, the atos :true and :false are used. You can also, write these atoms, not using colon character, like :true == true, :false == false.

The same for nil == nil atom.

2.6 Tuples

Tuples are intended as fixed-size containers for multiple elements.

Note: the function put_elem doesn’t modify the tuple. It returns the new version, keeping th old one unchanged. Remember that the data in Elixir is immutable.

2.7 Lists

Lists in Erlang are used to handle collections of data where the size can change dynamically.

Lists may look like arrays at first glance, but they behave like singly linked lists. To process or access elements, the list must be traversed from the beginning. As a result, most list operations have a time complexity of O(1)O(1).

Lists are never a good fit when direct access is called for.

In Elixir and Erlang, lists are built as recursive structures, where each list is made up of a head (the first element) and a tail (the rest of the list). This makes them easy to traverse recursively, which is a common pattern in functional programming. Because the list is essentially a chain of cons cells, accessing the head hd/1 or tl/1 are fast - O(1)O(1).

It’s much faster to add items to the beginning of a list than to the end. That’s because lists are built like chains - each item points to the next. Adding to the front is instant O(1)O(1), but adding to the end means walking through the whole list O(n)O(n). So it’s common to build lists by prepending and then reverse them if needed.

2.8 Maps

Maps are key-value data structures used to store and access data efficiently. Keys can be any type, but atoms are most common. Maps are great for representing structured data, like user profiles or settings.

# Creating a map
user = %{name: "Alice", age: 30}

# Accessing values

user.name # => "Alice"
user[:age] # => 30

# Updating values (returns a new map)

updated_user = %{user | age: 31}

# Adding a new key

new_user = Map.put(user, :email, "alice@example.com")

Maps are immutable, so updates return a new version of the map, not an in-place change. They are very fast for lookups and are one of the most commonly used data types in Elixir.

2.9 Functions

In Elixir, you can create anonymous functions using the fn -> ... end syntax and assign them to variables. This allows you to treat functions like values - you can store them, pass them around, or call them later. To invoke an anonymous function, you use a dot: f.(arg). This makes it easy to write flexible, reusable logic without naming the function globally.

greet = fn name -> "Hello, #{name}!" end
greet.("Alice")  # => "Hello, Alice!"

This pattern is especially useful for short callbacks, Enum operations, or when you want to keep logic scoped and lightweight.

2.10 Range

Range is a simple abstraction that represents a sequence of numbers with a start and end. You can create one using the .. syntax, like 1..5. Ranges are lazy and don’t store all elements in memory - they’re just a start and end value, which makes them efficient for iteration. They work great with Enum, for, and pattern matching.

2.11 MapSet

MapSet is the implementation of a set - a store of unique values, where a value can be of any type.

2.12 Sigil

Sigils are a special syntax used to work with text, regexes, lists, and more in a concise way. They start with a ~ followed by a letter that indicates the type, like ~s for strings or ~r for regular expressions. Sigils make it easier to write certain kinds of data without escaping characters or using long syntax.

~s(Hello\nWorld)   # => "Hello\nWorld" (interprets escapes)
~S(Hello\nWorld)   # => "Hello\\nWorld" (literal, no escape)

~r/hello/ # => a regex pattern
~w(one two three) # => ["one", "two", "three"]

2.13 Variable rebinding

In Elixir, variables are immutable, but they can be rebound - meaning you can assign a new value to the same variable name in a new context.

You’re not changing the existing value; you’re simply creating a new binding for that name. This works because variable names in Elixir are just labels for values, and they can point to something else in a new line or scope.

x = 1
x = x + 1  # x is now 2, but the original 1 wasn't mutated

Rebinding feels flexible, but it’s still safe and functional - there’s no mutation happening under the hood.

And since old values are no longer referenced, Elixir’s per-process garbage collector will clean them up efficiently, keeping memory usage low and predictable even in long-running systems.


3 Control flow

3.1 Pattern matching

3.1.1 The match operator

In Elixir, the = operator is not about assigning a value - it’s a pattern matcher. It tries to match the structure and shape of the value on the right-hand side with the pattern on the left. If the pattern matches, values are bound to the corresponding variables. If not, Elixir raises a MatchError.

Pattern matching works with tuples, lists, maps, structs, function arguments, case expressions, and more - and it’s one of the most expressive features in the language.

{a, b} = {1, 2}
# a = 1, b = 2

%{name: name} = %{name: "Alice"}
# name = "Alice"

[head | tail] = [1, 2, 3]
# head = 1, tail = [2, 3]

Pattern matching lets you deconstruct data directly, instead of manually accessing fields or indices. It also powers function overloading - you can define multiple versions of a function that match different patterns:

def handle_response({:ok, data}), do: "Success: #{data}"
def handle_response({:error, reason}), do: "Error: #{reason}"

This makes Elixir code concise, expressive, and often branch-free, since much of your control flow happens through matching patterns instead of using if or switch.

3.1.2 The pin operator

Variables are usually rebound when used in pattern matching - meaning they get a new value if the pattern matches. But sometimes, you want to match against the current value of a variable instead of rebinding it. That’s where the ^ operator (called the pin operator) comes in.

It “pins” the variable to its existing value, so pattern matching only succeeds if the value matches exactly

x = 5

case 5 do
^x -> "Matched!" # uses the value of x (5)
\_ -> "No match"
end

Without the ^, Elixir would treat x as a new variable and try to bind a new value to it. With the ^, you’re saying: “this must match the current value of x”.

This is especially useful in case, with, and function clauses when you want to enforce an exact match.

3.1.3 Guards

Guards are additional conditions you can add to pattern matches to make them more precise. They let you run simple checks like comparisons, type checks, or math. Guards use the when keyword.

def check_age(age) when age >= 18, do: "Adult"
def check_age(_), do: "Minor"

Only certain functions (called guard-safe) are allowed in guards, like is_integer/1, length/1, >/2, etc.

3.2 Tail recursion

In Elixir, iteration is often done using recursion, since there are no traditional loop constructs like for or while. To keep recursive code efficient, Elixir supports tail call optimization - but only when the function is written in a tail-recursive way.

A function is tail-recursive when the recursive call is the very last thing the function does. This allows the VM to reuse the current stack frame, meaning it doesn’t have to keep a growing stack of calls in memory. As a result, tail-recursive functions can safely handle very large lists without blowing the stack.

Tail-recursive version:

defmodule Sum do
  def total([], acc), do: acc
  def total([head | tail], acc), do: total(tail, acc + head)
end

Sum.total([1, 2, 3], 0) # => 6

Here, the recursive call to total/2 is the final action, so the VM can optimize it.

Non-tail-recursive version:

defmodule Sum do
  def total([]), do: 0
  def total([head | tail]), do: head + total(tail)
end

In this version, total(tail) is called, but then its result is added to head, meaning the recursive call isn’t the final action. The result is not tail-recursive, and each call must wait for the next one to finish - this builds up a call stack.

3.2.1 Tail recursion performance

Both tail and non-tail recursive functions usually have O(n)O(n) time complexity, since each element must be visited once. But the memory usage is very different:

3.2.2 Recursion tips

When writing recursive functions in Elixir:


4 Concurrency

In the BEAM, the core unit of concurrency is the process - a lightweight, isolated building block that enables scalable, fault-tolerant, and distributed systems.

4.1 What is the basic concurrency primitive in Erlang?

The fundamental building block of concurrency in Erlang (and Elixir) is the process - but not an operating system process or thread.

These are lightweight, isolated processes managed entirely by the BEAM virtual machine. They’re extremely efficient, allowing systems to run thousands or even millions of them concurrently.

Each process has:

Examples:

# Here's how you might spawn a process in Elixir that simply prints a message:

spawn(fn -> IO.puts("Hello from a new process!") end)
% Here's how you might spawn a process in Elixir that simply prints a message:

spawn(fun() -> io:format("Hello from a new process!~n") end).

You can also send and receive messages:

# Start a process that waits for a message
pid = spawn(fn ->
  receive do
    {:ping, sender} -> send(sender, :pong)
  end
end)

# Send a message and wait for the response

send(pid, {:ping, self()})

receive do
:pong -> IO.puts("Got a pong!")
end
% Start a process that waits for a message
Pid = spawn(fun() ->
  receive
    {ping, Sender} -> Sender ! pong
  end
end),

% Send a message and wait for the response
Pid ! {ping, self()},

receive
  pong -> io:format("Got a pong!~n")
end.

4.2 Concurrency vs Parallelism

Although the terms are often used interchangeably, concurrency and parallelism are not the same.

Concurrency means dealing with many things at once - it’s about structuring your program to handle multiple tasks without waiting.

Parallelism is about doing many things literally at the same time, using multiple CPU cores.

You can think of concurrency as a design strategy, and parallelism as a performance optimization. Elixir excels at concurrency, and thanks to the BEAM’s scheduler, it can achieve parallelism when running on multicore CPUs - but it doesn’t depend on it.

4.3 GenServer

While spawning processes manually works for simple tasks, most real-world Elixir applications use GenServer, a built-in abstraction for building long-running, stateful, concurrent processes.

A GenServer handles messages, maintains internal state, and integrates seamlessly with OTP features like supervision, timeouts, and restarts.

Instead of writing raw receive blocks, you implement callback functions like handle_call/3 or handle_cast/2, and let the runtime handle the plumbing.

defmodule Counter do
  use GenServer

# Client API

def start_link(initial) do
GenServer.start_link(**MODULE**, initial, name: **MODULE**)
end

def increment(), do: GenServer.cast(**MODULE**, :inc)
def get(), do: GenServer.call(**MODULE**, :get)

# Server Callbacks

def init(initial), do: {:ok, initial}

def handle_cast(:inc, state), do: {:noreply, state + 1}
def handle_call(:get, \_from, state), do: {:reply, state, state}
end

With GenServer, you don’t have to manage low-level details - the BEAM handles process lifecycle, crashes, and messaging behind the scenes. It’s the foundation for building robust, fault-tolerant systems in Elixir.