In this article we explore a collection of everyday Elixir building blocks that make the language both expressive and efficient. We’ll look at how to handle optional arguments, work with unique collections, manipulate dates and times, craft performant output streams, understand the most common operators, dip our toes into macros, and peek under the hood of the BEAM runtime. Along the way you’ll see fresh, self‑contained examples that illustrate each idea from a practical perspective.
Every non‑trivial Elixir program relies on a few core abstractions:
- Keyword lists vs. maps – the idiomatic way to pass optional parameters.
- Set‑like collections – ensuring uniqueness without manual bookkeeping.
- Date/Time structs – handling timestamps, scheduling, and log entries.
- IO lists – building large byte streams efficiently.
- Operators – the syntax sugar that powers arithmetic, comparisons, and control flow.
- Macros – compile‑time code generators that let you write DSLs.
- Runtime mechanics – modules, dynamic calls, and the various ways to start Elixir code.
Mastering these pieces equips you to write clear, idiomatic, and high‑performance Elixir applications.
Keyword Lists vs. Maps for Optional Arguments
Elixir’s standard libraries, and most third‑party packages, expect optional arguments to be supplied as a keyword list. A keyword list is simply a list of two‑element tuples where the first element is an atom:
# A typical function signature
def send_email(to, subject, opts \\ [])
end
Why not just use a map instead? There are three practical reasons:
- Duplicate keys – Keyword lists can contain the same key multiple times, which is handy for “stacked” configurations (e.g., multiple
:headerentries). - Preserved order – The order you write the options is retained, allowing deterministic processing.
- Convention – The ecosystem expects keyword lists; deviating forces users to convert structures manually.
Fresh Example: Configuring a Game Character
Imagine a function that creates a character in a role‑playing game. The optional attributes (strength, agility, etc.) are supplied as a keyword list so the caller can decide the order and repeat keys if needed (e.g., multiple :skill entries).
defmodule RPG do
# The third argument defaults to an empty keyword list
def create_character(name, class, opts \\ []) do
# Merge defaults with the provided options
defaults = [strength: 10, agility: 10, skill: []]
opts = Keyword.merge(defaults, opts)
%{
name: name,
class: class,
strength: opts[:strength],
agility: opts[:agility],
# Collect all skill entries, even if the caller provided several
skill: Keyword.get_values(opts, :skill)
}
end
end
# Usage examples
hero = RPG.create_character("Aria", :wizard, strength: 12, skill: :fireball)
mage = RPG.create_character("Borin", :warrior,
agility: 14,
skill: :shield_block,
skill: :spear_thrust)
IO.inspect(hero)
IO.inspect(mage)
Notice how the skill key appears twice in the second call; Keyword.get_values/2 gathers them into a list, something a plain map could not express without extra ceremony.
Working with Unique Collections – MapSet
A MapSet is Elixir’s built‑in set implementation. It guarantees that each element appears exactly once, regardless of insertion order. Underneath it is a map where the set elements are keys and all values are true, but the API hides those details.
Fresh Example: Tracking Active Sessions
Suppose we run a small web service that needs to keep track of which session IDs are currently active. A MapSet gives us O(1) membership checks and prevents duplicates.
defmodule SessionTracker do
# Starts with an empty set
def new do
MapSet.new()
end
# Add a session – returns the new set
def add(set, session_id) do
MapSet.put(set, session_id)
end
# Remove a session – returns the new set
def remove(set, session_id) do
MapSet.delete(set, session_id)
end
# Quick membership test
def active?(set, session_id) do
MapSet.member?(set, session_id)
end
# Enumerate all active sessions (order is undefined)
def list(set) do
Enum.to_list(set)
end
end
# Demo
sessions = SessionTracker.new()
|> SessionTracker.add("abc123")
|> SessionTracker.add("def456")
|> SessionTracker.add("abc123") # Duplicate ignored
IO.puts("Is abc123 active? #{SessionTracker.active?(sessions, "abc123")}")
IO.puts("All active sessions: #{inspect(SessionTracker.list(sessions))}")
sessions = SessionTracker.remove(sessions, "def456")
IO.puts("After removal: #{inspect(SessionTracker.list(sessions))}")
If you need to perform set operations such as union, intersection, or difference, MapSet provides dedicated functions like MapSet.union/2, MapSet.intersection/2, and MapSet.difference/2.
Date, Time, and Date‑Time Types
Elixir ships with four primary structs for temporal data:
Date– a calendar date (year, month, day).Time– a time of day (hour, minute, second, microsecond).NaiveDateTime– a “timezone‑agnostic” datetime.DateTime– a fully‑qualified datetime that knows its time zone.
Each type comes with a convenient sigil for literal construction.
Fresh Example: Scheduling a Reminder System
We’ll build a tiny module that stores reminder timestamps in UTC, formats them for display, and checks whether a reminder is due.
defmodule Reminder do
@moduledoc false
# Parse a human‑readable string into a NaiveDateTime.
# Expected format: "YYYY‑MM‑DD HH:MM"
def parse_naive(str) do
case NaiveDateTime.from_iso8601(str <> ":00") do
{:ok, ndt} -> ndt
{:error, _} -> raise "invalid format"
end
end
# Convert a NaiveDateTime to a UTC DateTime
def to_utc(ndt) do
DateTime.from_naive!(ndt, "Etc/UTC")
end
# Return a formatted string like "Fri, 12 Jul 2024 14:30 UTC"
def format(dt) do
Calendar.Strftime.strftime(dt, "%a, %d %b %Y %H:%M %Z")
end
# Is the reminder time already reached?
def due?(dt) do
DateTime.compare(dt, DateTime.utc_now()) != :gt
end
end
# Example usage
naive = Reminder.parse_naive("2024-07-12 14:30")
utc = Reminder.to_utc(naive)
IO.puts("Reminder set for: #{Reminder.format(utc)}")
IO.puts("Is it due? #{Reminder.due?(utc)}")
Key takeaways:
- Use sigils (
~D,~T,~N) for literals when possible. - Convert a
NaiveDateTimeto aDateTimeby providing a time‑zone identifier. - Functions like
DateTime.compare/2and theCalendar.Strftimelibrary make reasoning about time simple.
IO Lists – Efficient Byte Stream Construction
When you need to emit large amounts of data (e.g., HTTP responses, binary files, or network packets) you often want to avoid the costly <> concatenation of binaries. An IO list solves this: it’s a nested list whose leaves are either integers between 0‑255, binaries, or other IO lists. The BEAM flattens the structure lazily when it reaches an I/O driver.
Fresh Example: Building a Simple TCP Packet
Suppose we have a binary protocol where a packet consists of:
- 1‑byte command code
- 2‑byte big‑endian payload length
- Payload (arbitrary binary)
Instead of building a huge binary repeatedly, we compose an IO list.
defmodule TcpPacket do
# Encodes a packet using an IO list
def encode(command, payload) when is_integer(command) and is_binary(payload) do
length = byte_size(payload)
# <> creates a 2‑byte binary
length_bin = <>
# The packet is a list that contains integers, binaries, and sub‑lists
[command, length_bin, payload]
end
# Sends a packet over a socket (mocked with IO.inspect/1)
def send(socket, packet_iolist) do
# In a real system you'd call :gen_tcp.send(socket, packet_iolist)
IO.inspect({:send, socket, packet_iolist}, label: "Sending")
end
end
# Demo
payload = "Hello, world!"
packet = TcpPacket.encode(0x01, payload)
TcpPacket.send(:my_socket, packet)
Even though the packet list contains three elements, sending it through :gen_tcp.send/2 would cause the runtime to flatten it just once, making it O(1) with respect to the payload size. This pattern scales nicely when you need to prepend headers or trailers many times.
Operators – The Building Blocks of Expressions
Elixir’s operators are thin wrappers around ordinary functions defined in the Kernel module. Understanding their semantics helps you write clear, idiomatic code and also enables you to turn operators into anonymous functions when needed.
Arithmetic and Comparison
All arithmetic operators return numbers; division always yields a float. Comparison operators come in two flavors:
===/!==– strict equality (no type coercion).==/!=– “weak” equality; e.g.,1 == 1.0istrue.
Example:
a = 5
b = 5.0
IO.puts("a == b? #{a == b}") # true – weak equality
IO.puts("a === b? #{a === b}") # false – strict equality
Logical vs. Short‑Circuit Operators
Logical operators (and, or, not) work with the Boolean literals true and false. Short‑circuit operators (&&, ||) treat nil and false as falsy and everything else as truthy. The result of && and || is the *actual* operand that determines the outcome, which is handy for safe navigation.
user = %{name: "Ada", age: nil}
# Returns nil because the left side is falsy
age = user.age && "Age: #{user.age}"
IO.inspect(age) # nil
# Returns a default string because the left side is falsy
age = user.age || "Age unknown"
IO.inspect(age) # "Age unknown"
Turning Operators into Functions
Since operators are regular functions, you can capture them with the capture syntax (&/1, &/2). This is useful when passing an operator to higher‑order functions like Enum.map/2.
numbers = [1, 2, 3, 4]
# Double each number using the + operator as a function
doubled = Enum.map(numbers, &(&1 * 2))
IO.inspect(doubled)
# Filter out even numbers using the rem/2 operator
evens = Enum.filter(numbers, &(rem(&1, 2) == 0))
IO.inspect(evens)
Macros – Compile‑time Code Generation
Macros let you transform the abstract syntax tree (AST) of the code you write, producing new code before the compiler emits bytecode. They are the engine behind many of Elixir’s “magical” constructs (if, unless, defmodule, etc.). While powerful, macros should be used sparingly and with good documentation.
Fresh Example: A Simple log_if Macro
Suppose we often want to log a message only when a condition is true, but we don’t want the overhead of evaluating the log expression when the condition is false. A macro can achieve this by generating an if block at compile time.
defmodule MyMacros do
# The macro receives raw AST fragments for the condition and the message.
defmacro log_if(condition, message) do
quote do
if unquote(condition) do
IO.puts("[LOG] " <> unquote(message))
end
end
end
end
defmodule Demo do
import MyMacros
def run(value) do
# The log_if macro expands to an if‑statement, avoiding runtime string interpolation
log_if(value > 10, "Value #{value} exceeds the threshold")
value * 2
end
end
IO.puts("Result: #{Demo.run(5)}")
IO.puts("Result: #{Demo.run(12)}")
When you compile this file, the call to log_if/2 is replaced by the “expanded” if expression. This means the IO.puts call only exists in the generated bytecode when the condition is true, eliminating any unnecessary runtime work.
Runtime Basics – Modules, Functions, and Dynamic Calls
At runtime the BEAM virtual machine loads compiled modules (files ending in .beam). Modules are identified by atoms; for an Elixir module named MyApp.Worker the underlying atom is :\"Elixir.MyApp.Worker\". When you call MyApp.Worker.start/0, the VM checks whether that module is already loaded; if not, it loads the corresponding .beam file from the code path.
Dynamic Function Invocation with apply/3
Sometimes the function you need to invoke is not known until runtime—for instance, when implementing a plug‑in system. Kernel.apply/3 takes a module atom, a function name atom, and a list of arguments, then invokes the function.
defmodule MathOps do
def add(a, b), do: a + b
def mul(a, b), do: a * b
end
defmodule DynamicCaller do
def call(mod, fun, args) do
apply(mod, fun, args)
end
end
IO.puts("3 + 7 = #{DynamicCaller.call(MathOps, :add, [3, 7])}")
IO.puts("4 * 5 = #{DynamicCaller.call(MathOps, :mul, [4, 5])}")
This pattern is especially useful for routing requests to handler modules based on configuration files or user input.
Running Elixir Code – Scripts, Interactive Shell, and Mix Projects
Elixir offers several entry points for running code:
- Interactive shell (
iex) – great for experimentation; code entered at the prompt is interpreted. - Standalone script (
elixir) – a single.exsfile is compiled in memory and executed; useful for quick tasks. - Mix project – the standard way to build, test, and package multi‑file applications.
Fresh Example: A Command‑Line Script for CSV Summaries
Below is a self‑contained script (csv_summary.exs) that reads a CSV file, computes the sum of numbers in the second column, and prints the result. Run it with elixir csv_summary.exs data.csv.
# csv_summary.exs
defmodule CsvSummary do
@moduledoc false
# Reads a file path from the command line and returns a stream of rows
def rows_from_file(path) do
File.stream!(path)
|> Stream.map(&String.trim/1)
|> Stream.filter(&(&1 != ""))
|> Stream.map(&String.split(&1, ","))
end
# Sums the numeric values in column index (0‑based) across all rows
def column_sum(rows, index) do
rows
|> Enum.reduce(0, fn row, acc ->
case Integer.parse(Enum.at(row, index)) do
{value, _} -> acc + value
:error -> acc
end
end)
end
end
# Entry point
[_, file_path] = System.argv()
sum = CsvSummary.rows_from_file(file_path) |> CsvSummary.column_sum(1)
IO.puts("Sum of column 2: #{sum}")
This script demonstrates the typical lifecycle: the file is read lazily as a stream, the data is transformed, and finally a computation is performed. No mix project is needed, yet the code remains clean and testable.
Mix – The Full‑Featured Build Tool
When your codebase expands beyond a single file, mix becomes indispensable. It handles compilation, dependency management, test execution, and release packaging. Creating a new project with mix new my_app gives you a ready‑to‑run skeleton with an mix.exs configuration file.
Typical commands you’ll use daily:
mix compile– compiles all.exfiles underlib/.mix test– runs all tests undertest/.mix run– executes a snippet or a module’smain/0.mix deps.get– fetches external packages declared inmix.exs.
The output of mix compile places .beam files inside _build/dev/lib/your_app/ebin/, and the BEAM automatically adds this directory to its code path, making your modules available at runtime.
Common Pitfalls and How to Avoid Them
- Mixing Maps and Keyword Lists – Remember that keyword lists preserve order and allow duplicate keys; maps do not. Use the appropriate container for the API you are interfacing with.
- Assuming IO List Order – IO lists do not guarantee the order of elements after flattening if you mix integers and binaries incorrectly. Always keep the intended byte order explicit (e.g., by using binary pattern syntax).
- Using
==for Equality Checks – When you need strict type safety, prefer===to avoid unexpected “coercion”. - Neglecting Time Zones – Converting a
NaiveDateTimeto aDateTimewithout specifying a zone can lead to subtle bugs in distributed systems. - Calling
apply/3Blindly – Dynamic invocation bypasses compile‑time checks; make sure the module and function actually exist, or handleUndefinedFunctionErrorgracefully. - Running Heavy Benchmarks in
iex– The interactive shell interprets code, causing misleading performance numbers. Use compiled modules or theBencheelibrary for reliable profiling.
Summary – Key Takeaways
- Keyword lists are the idiomatic way to pass optional arguments because they preserve order and allow duplicate keys.
MapSetprovides O(1) membership checks and automatic deduplication, ideal for tracking unique items.- Use
Date,Time,NaiveDateTime, andDateTimefor temporal data; sigils make literals concise. - IO lists enable O(1) construction of large byte streams, perfect for network or file I/O.
- Almost all operators are regular functions; they can be captured and passed around like any other function.
- Macros let you generate code at compile time; a simple macro can replace repetitive boilerplate (see
log_if). - Modules are identified by atoms on the BEAM;
apply/3offers dynamic dispatch when needed. - Choose the right execution model:
iexfor REPL work,elixirfor scripts,mixfor full‑scale applications.
With these building blocks under your belt, you’re ready to craft clean, performant, and idiomatic Elixir programs that take full advantage of the language’s strengths.