Agent Skills: Elixir Fundamentals

Foundational Elixir patterns covering modules, pattern matching, processes, OTP behaviors (GenServer, Supervisor), Phoenix framework basics, and functional programming idioms. Use when writing Elixir code, building concurrent systems, working with Phoenix, or needing guidance on Elixir development patterns. This is the entry point for Elixir development.

UncategorizedID: arustydev/ai/lang-elixir-dev

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/skills/lang-elixir-dev

Skill Files

Browse the full folder contents for lang-elixir-dev.

Download Skill

Loading file tree…

content/skills/lang-elixir-dev/SKILL.md

Skill Metadata

Name
lang-elixir-dev
Description
Foundational Elixir patterns covering modules, pattern matching, processes, OTP behaviors (GenServer, Supervisor), Phoenix framework basics, and functional programming idioms. Use when writing Elixir code, building concurrent systems, working with Phoenix, or needing guidance on Elixir development patterns. This is the entry point for Elixir development.

Elixir Fundamentals

Foundational Elixir patterns and core language features. This skill serves as both a reference for common patterns and an index to specialized Elixir skills.

Overview

┌─────────────────────────────────────────────────────────────────┐
│                    Elixir Skill Hierarchy                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│                  ┌───────────────────┐                          │
│                  │ lang-elixir-dev   │ ◄── You are here         │
│                  │   (foundation)    │                          │
│                  └─────────┬─────────┘                          │
│                            │                                    │
│       ┌────────────────────┼────────────────────┐               │
│       │                    │                    │               │
│       ▼                    ▼                    ▼               │
│  ┌─────────┐        ┌──────────┐        ┌──────────┐           │
│  │ phoenix │        │   otp    │        │  ecto    │           │
│  │  -dev   │        │  -dev    │        │  -dev    │           │
│  └─────────┘        └──────────┘        └──────────┘           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

This skill covers:

  • Core syntax (modules, functions, pattern matching)
  • Data types and immutability
  • Pattern matching and guards
  • Processes and message passing
  • OTP basics (GenServer, Supervisor)
  • Mix project structure
  • Common functional programming idioms
  • Phoenix framework fundamentals

This skill does NOT cover (see specialized skills):

  • Advanced Phoenix features → lang-elixir-phoenix-dev
  • Advanced OTP patterns → lang-elixir-otp-dev
  • Database patterns with Ecto → lang-elixir-ecto-dev
  • Deployment and releases → lang-elixir-deploy-dev
  • Testing patterns → lang-elixir-testing-dev

Quick Reference

| Task | Pattern | |------|---------| | Define module | defmodule MyModule do ... end | | Define function | def function_name(args), do: result | | Private function | defp private_function(args), do: result | | Pattern match | {:ok, value} = result | | Pipe operator | value \|> function1() \|> function2() | | Spawn process | spawn(fn -> ... end) | | Send message | send(pid, message) | | Receive message | receive do pattern -> ... end | | GenServer call | GenServer.call(pid, message) | | Supervisor start | Supervisor.start_link(children, opts) |


Skill Routing

Use this table to find the right specialized skill:

| When you need to... | Use this skill | |---------------------|----------------| | Build web applications with Phoenix | lang-elixir-phoenix-dev | | Design advanced OTP architectures | lang-elixir-otp-dev | | Work with databases using Ecto | lang-elixir-ecto-dev | | Deploy applications, create releases | lang-elixir-deploy-dev | | Write tests with ExUnit | lang-elixir-testing-dev |


Core Data Types

Atoms

# Atoms are constants where name is the value
:ok
:error
:atom_name
:"atom with spaces"

# Boolean atoms
true  # Same as :true
false # Same as :false
nil   # Same as :nil

# Module names are atoms
IO      # Same as :"Elixir.IO"
String  # Same as :"Elixir.String"

Numbers

# Integers (arbitrary precision)
42
1_000_000
0x1F  # Hexadecimal
0o777 # Octal
0b1010 # Binary

# Floats (64-bit double precision)
3.14
1.0e-10

Strings and Charlists

# Strings (UTF-8 binaries)
"hello"
"hello #{name}"  # Interpolation
"multi
line
string"

# String operations
String.upcase("hello")      # "HELLO"
String.length("hello")      # 5
String.split("a,b,c", ",")  # ["a", "b", "c"]
String.replace("hello", "l", "x")  # "hexxo"

# Charlists (lists of integers)
'hello'  # [104, 101, 108, 108, 111]
'hello' ++ ' world'  # 'hello world'

Lists

# Lists (linked lists)
[1, 2, 3]
[head | tail] = [1, 2, 3]  # head = 1, tail = [2, 3]

# List operations
[1, 2] ++ [3, 4]     # [1, 2, 3, 4] (concatenation)
[1, 2, 3] -- [2]     # [1, 3] (difference)
hd([1, 2, 3])        # 1 (head)
tl([1, 2, 3])        # [2, 3] (tail)
length([1, 2, 3])    # 3

# List module
Enum.map([1, 2, 3], fn x -> x * 2 end)  # [2, 4, 6]
Enum.filter([1, 2, 3], fn x -> rem(x, 2) == 0 end)  # [2]
Enum.reduce([1, 2, 3], 0, fn x, acc -> x + acc end)  # 6

Tuples

# Tuples (contiguous memory)
{:ok, "value"}
{:error, :not_found}
{1, 2, 3}

# Common pattern: tagged tuples
def divide(a, b) when b != 0, do: {:ok, a / b}
def divide(_, 0), do: {:error, :division_by_zero}

# Tuple operations
elem({:ok, 42}, 0)           # :ok
put_elem({:a, :b}, 1, :c)    # {:a, :c}
tuple_size({:a, :b, :c})     # 3

Maps

# Maps (key-value stores)
%{:name => "Alice", :age => 30}
%{name: "Alice", age: 30}  # Shorthand for atom keys

# Accessing values
map = %{name: "Alice", age: 30}
map[:name]       # "Alice"
map.name         # "Alice" (only for atom keys)

# Updating maps
%{map | age: 31}  # Update existing key
Map.put(map, :city, "NYC")  # Add new key
Map.delete(map, :age)  # Remove key

# Pattern matching
%{name: name} = %{name: "Alice", age: 30}  # name = "Alice"

# Map operations
Map.keys(map)       # [:name, :age]
Map.values(map)     # ["Alice", 30]
Map.merge(map1, map2)  # Merge maps

Keyword Lists

# Keyword lists (lists of 2-tuples with atom keys)
[name: "Alice", age: 30]
# Same as: [{:name, "Alice"}, {:age, 30}]

# Common in function options
String.split("a,b,c", ",", trim: true)

# Accessing values
list = [name: "Alice", age: 30]
list[:name]  # "Alice"
Keyword.get(list, :name)  # "Alice"

# Can have duplicate keys
[a: 1, a: 2, a: 3]

Pattern Matching

Basic Patterns

# Match operator
{a, b, c} = {1, 2, 3}  # a=1, b=2, c=3

# List matching
[head | tail] = [1, 2, 3]  # head=1, tail=[2,3]
[first, second | rest] = [1, 2, 3, 4]  # first=1, second=2, rest=[3,4]

# Tuple matching
{:ok, result} = {:ok, 42}  # result=42
{:error, reason} = {:error, :not_found}  # reason=:not_found

# Map matching
%{name: name} = %{name: "Alice", age: 30}  # name="Alice"
%{name: name, age: age} = %{name: "Alice", age: 30}

# Pin operator (match against value)
x = 1
^x = 1  # OK
^x = 2  # MatchError

Function Pattern Matching

# Multiple function clauses
def greet(:morning), do: "Good morning!"
def greet(:afternoon), do: "Good afternoon!"
def greet(:evening), do: "Good evening!"

# Pattern match on structure
def handle_response({:ok, data}), do: process(data)
def handle_response({:error, reason}), do: handle_error(reason)

# Pattern match on lists
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)

# Ignore values with underscore
def process({:ok, _}), do: :success
def process({:error, reason}), do: {:failed, reason}

Guards

# Guard clauses
def positive?(x) when x > 0, do: true
def positive?(_), do: false

# Multiple guards (AND)
def adult?(age) when is_integer(age) and age >= 18, do: true
def adult?(_), do: false

# Multiple guards (OR)
def number?(x) when is_integer(x) or is_float(x), do: true
def number?(_), do: false

# Common guard functions
is_atom(x)
is_binary(x)
is_boolean(x)
is_list(x)
is_map(x)
is_number(x)
is_tuple(x)
is_nil(x)

Modules and Functions

Module Definition

defmodule Math do
  # Module attribute (compile-time constant)
  @pi 3.14159

  # Public function
  def circle_area(radius) do
    @pi * radius * radius
  end

  # Private function
  defp validate_radius(radius) when radius > 0, do: :ok
  defp validate_radius(_), do: :error

  # One-line function
  def double(x), do: x * 2

  # Function with multiple clauses
  def factorial(0), do: 1
  def factorial(n) when n > 0, do: n * factorial(n - 1)

  # Function with default arguments
  def greet(name, greeting \\ "Hello") do
    "#{greeting}, #{name}!"
  end
end

Anonymous Functions

# Define anonymous function
add = fn a, b -> a + b end
add.(1, 2)  # 3 (note the dot)

# Shorthand syntax
add = &(&1 + &2)
add.(1, 2)  # 3

# Capture operator with named functions
doubled = Enum.map([1, 2, 3], &(&1 * 2))
lengths = Enum.map(["a", "ab", "abc"], &String.length/1)

# Multiple clauses
handle = fn
  {:ok, result} -> result
  {:error, _} -> nil
end

Function Composition

# Pipe operator
"hello"
|> String.upcase()
|> String.reverse()
# "OLLEH"

# Function capture
numbers = [1, 2, 3, 4, 5]
evens = Enum.filter(numbers, &(rem(&1, 2) == 0))

# Composition example
def process_data(data) do
  data
  |> String.trim()
  |> String.downcase()
  |> String.split(",")
  |> Enum.map(&String.trim/1)
  |> Enum.reject(&(&1 == ""))
end

Processes and Concurrency

Spawning Processes

# Spawn a process
pid = spawn(fn ->
  receive do
    {:hello, sender} -> send(sender, {:ok, "Hello back!"})
  end
end)

# Send message
send(pid, {:hello, self()})

# Receive message
receive do
  {:ok, message} -> IO.puts(message)
after
  1000 -> IO.puts("Timeout!")
end

Process Links and Monitoring

# Spawn linked process (failures propagate)
pid = spawn_link(fn -> raise "Error!" end)

# Trap exits to handle linked process failures
Process.flag(:trap_exit, true)
receive do
  {:EXIT, pid, reason} -> IO.puts("Process died: #{inspect(reason)}")
end

# Monitor process (doesn't link)
{:ok, pid} = some_process()
ref = Process.monitor(pid)
receive do
  {:DOWN, ^ref, :process, ^pid, reason} ->
    IO.puts("Process down: #{inspect(reason)}")
end

Process State with Recursion

# Counter process
defmodule Counter do
  def start(initial_value) do
    spawn(fn -> loop(initial_value) end)
  end

  defp loop(value) do
    receive do
      {:increment, caller} ->
        send(caller, {:ok, value + 1})
        loop(value + 1)

      {:get, caller} ->
        send(caller, {:ok, value})
        loop(value)

      :stop ->
        :ok
    end
  end
end

# Usage
counter = Counter.start(0)
send(counter, {:increment, self()})
receive do
  {:ok, new_value} -> IO.puts("New value: #{new_value}")
end

OTP Basics

GenServer

defmodule Stack do
  use GenServer

  # Client API

  def start_link(initial_stack) do
    GenServer.start_link(__MODULE__, initial_stack, name: __MODULE__)
  end

  def push(item) do
    GenServer.cast(__MODULE__, {:push, item})
  end

  def pop do
    GenServer.call(__MODULE__, :pop)
  end

  # Server Callbacks

  @impl true
  def init(initial_stack) do
    {:ok, initial_stack}
  end

  @impl true
  def handle_call(:pop, _from, [head | tail]) do
    {:reply, head, tail}
  end

  def handle_call(:pop, _from, []) do
    {:reply, nil, []}
  end

  @impl true
  def handle_cast({:push, item}, state) do
    {:noreply, [item | state]}
  end
end

# Usage
{:ok, _pid} = Stack.start_link([])
Stack.push(1)
Stack.push(2)
Stack.pop()  # 2

Supervisor

defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Worker
      {Stack, []},
      # Worker with custom config
      {MyWorker, name: MyWorker, restart: :transient},
      # Supervisor
      {Registry, keys: :unique, name: MyApp.Registry}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

# Restart strategies:
# :one_for_one - restart only failed child
# :one_for_all - restart all children if one fails
# :rest_for_one - restart failed child and those started after it

GenServer with State Management

defmodule Counter do
  use GenServer

  # Client

  def start_link(initial_value \\ 0) do
    GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
  end

  def increment do
    GenServer.call(__MODULE__, :increment)
  end

  def get do
    GenServer.call(__MODULE__, :get)
  end

  def async_increment do
    GenServer.cast(__MODULE__, :increment)
  end

  # Server

  @impl true
  def init(initial_value) do
    {:ok, initial_value}
  end

  @impl true
  def handle_call(:increment, _from, state) do
    {:reply, state + 1, state + 1}
  end

  def handle_call(:get, _from, state) do
    {:reply, state, state}
  end

  @impl true
  def handle_cast(:increment, state) do
    {:noreply, state + 1}
  end
end

Mix Project Structure

Creating a New Project

# Create new project
mix new my_app

# Create new supervised application
mix new my_app --sup

# Project structure:
# my_app/
# ├── lib/
# │   ├── my_app.ex
# │   └── my_app/
# │       └── application.ex
# ├── test/
# │   ├── my_app_test.exs
# │   └── test_helper.exs
# ├── mix.exs
# └── README.md

mix.exs Configuration

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.14",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  def application do
    [
      extra_applications: [:logger],
      mod: {MyApp.Application, []}
    ]
  end

  defp deps do
    [
      {:phoenix, "~> 1.7"},
      {:ecto_sql, "~> 3.10"},
      {:postgrex, ">= 0.0.0"},
      {:jason, "~> 1.4"},
      {:plug_cowboy, "~> 2.6"}
    ]
  end
end

Common Mix Tasks

# Compile project
mix compile

# Run tests
mix test

# Run application
mix run --no-halt

# Interactive shell
iex -S mix

# Get dependencies
mix deps.get

# Format code
mix format

# Create new module
mix phx.gen.context Accounts User users name:string email:string

Phoenix Framework Basics

Phoenix Project Structure

my_app/
├── assets/           # Frontend assets
├── config/           # Configuration
├── lib/
│   ├── my_app/       # Business logic
│   ├── my_app_web/   # Web interface
│   │   ├── controllers/
│   │   ├── views/
│   │   ├── templates/
│   │   └── router.ex
│   └── my_app.ex
├── priv/             # Database migrations, static files
└── test/

Router

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", MyAppWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/users", UserController
  end

  scope "/api", MyAppWeb do
    pipe_through :api

    resources "/posts", PostController, except: [:new, :edit]
  end
end

Controller

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller

  alias MyApp.Accounts
  alias MyApp.Accounts.User

  def index(conn, _params) do
    users = Accounts.list_users()
    render(conn, "index.html", users: users)
  end

  def show(conn, %{"id" => id}) do
    user = Accounts.get_user!(id)
    render(conn, "show.html", user: user)
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: ~p"/users/#{user}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
end

LiveView Basics

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  @impl true
  def handle_event("increment", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("decrement", _params, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <h1>Count: <%= @count %></h1>
      <button phx-click="increment">+</button>
      <button phx-click="decrement">-</button>
    </div>
    """
  end
end

Common Patterns and Idioms

With Statement

# Chain operations that can fail
def create_user(params) do
  with {:ok, validated} <- validate_params(params),
       {:ok, user} <- insert_user(validated),
       {:ok, email} <- send_welcome_email(user) do
    {:ok, user}
  else
    {:error, reason} -> {:error, reason}
  end
end

Case Statement

case File.read("config.json") do
  {:ok, contents} ->
    Jason.decode(contents)

  {:error, :enoent} ->
    {:error, "File not found"}

  {:error, reason} ->
    {:error, "Read error: #{inspect(reason)}"}
end

Cond Statement

cond do
  age < 13 -> "child"
  age < 20 -> "teenager"
  age < 60 -> "adult"
  true -> "senior"
end

Using Structs

defmodule User do
  defstruct [:name, :email, age: 0, active: true]

  def new(name, email) do
    %User{name: name, email: email}
  end

  def activate(%User{} = user) do
    %{user | active: true}
  end
end

# Usage
user = %User{name: "Alice", email: "alice@example.com"}
user = User.activate(user)

# Pattern matching
def greet(%User{name: name}), do: "Hello, #{name}!"

Protocols

# Define protocol
defprotocol Serializable do
  def serialize(data)
end

# Implement for different types
defimpl Serializable, for: Map do
  def serialize(map), do: Jason.encode!(map)
end

defimpl Serializable, for: List do
  def serialize(list), do: Jason.encode!(list)
end

# Usage
Serializable.serialize(%{name: "Alice"})
Serializable.serialize([1, 2, 3])

Use Directive

# Define reusable behavior
defmodule MyMacro do
  defmacro __using__(opts) do
    quote do
      import MyMacro
      @opts unquote(opts)

      def common_function do
        "This is common"
      end
    end
  end
end

# Use it
defmodule MyModule do
  use MyMacro, option: :value
end

Enum and Stream

Enum Module (Eager)

# Map
Enum.map([1, 2, 3], fn x -> x * 2 end)  # [2, 4, 6]

# Filter
Enum.filter([1, 2, 3, 4], fn x -> rem(x, 2) == 0 end)  # [2, 4]

# Reduce
Enum.reduce([1, 2, 3], 0, fn x, acc -> x + acc end)  # 6

# Find
Enum.find([1, 2, 3], fn x -> x > 2 end)  # 3

# Chaining
[1, 2, 3, 4, 5]
|> Enum.filter(&(rem(&1, 2) == 0))
|> Enum.map(&(&1 * 2))
# [4, 8]

# Common functions
Enum.any?([1, 2, 3], &(&1 > 2))      # true
Enum.all?([1, 2, 3], &(&1 > 0))      # true
Enum.count([1, 2, 3])                # 3
Enum.sum([1, 2, 3])                  # 6
Enum.max([1, 2, 3])                  # 3
Enum.min([1, 2, 3])                  # 1
Enum.sort([3, 1, 2])                 # [1, 2, 3]
Enum.uniq([1, 2, 2, 3])              # [1, 2, 3]
Enum.zip([1, 2], [:a, :b])           # [{1, :a}, {2, :b}]

Stream Module (Lazy)

# Lazy operations (only computed when needed)
stream = Stream.map([1, 2, 3], fn x -> x * 2 end)
Enum.to_list(stream)  # [2, 4, 6]

# Infinite streams
Stream.iterate(0, &(&1 + 1))
|> Stream.take(5)
|> Enum.to_list()
# [0, 1, 2, 3, 4]

# File streaming (memory efficient)
File.stream!("large_file.txt")
|> Stream.map(&String.trim/1)
|> Stream.filter(&(&1 != ""))
|> Enum.count()

Error Handling

Try-Rescue

try do
  raise "Error!"
rescue
  e in RuntimeError -> "Caught runtime error: #{e.message}"
  e -> "Caught: #{inspect(e)}"
after
  cleanup()
end

Try-Catch

try do
  throw(:error)
catch
  :error -> "Caught thrown value"
  :exit, _ -> "Caught exit"
end

Result Tuples

# Preferred pattern in Elixir
def divide(a, b) when b != 0, do: {:ok, a / b}
def divide(_, 0), do: {:error, :division_by_zero}

# Usage
case divide(10, 2) do
  {:ok, result} -> "Result: #{result}"
  {:error, reason} -> "Error: #{reason}"
end

# With pattern matching
{:ok, result} = divide(10, 2)  # Raises if not :ok

Troubleshooting

Match Error

Problem: MatchError: no match of right hand side value

# Cause: Pattern doesn't match
{:ok, value} = {:error, :not_found}  # MatchError!

# Fix: Use case or handle both patterns
case result do
  {:ok, value} -> value
  {:error, reason} -> handle_error(reason)
end

Undefined Function

Problem: UndefinedFunctionError

# Cause: Function not defined or not imported
String.upcase("hello")  # OK
upcase("hello")         # UndefinedFunctionError

# Fix: Import or use full module name
import String
upcase("hello")  # OK

Process Crashes

Problem: Process dies unexpectedly

# Use supervisors to restart failed processes
children = [
  {MyWorker, restart: :permanent}
]

Supervisor.start_link(children, strategy: :one_for_one)

Genserver Timeout

Problem: GenServer call times out

# Default timeout is 5 seconds
GenServer.call(server, :slow_operation)  # May timeout

# Increase timeout
GenServer.call(server, :slow_operation, 30_000)  # 30 seconds

# Or use cast for async operations
GenServer.cast(server, :slow_operation)

Testing

Elixir has excellent testing support built-in with ExUnit, along with doctests, property-based testing, and mocking capabilities.

ExUnit Basics

# test/math_test.exs
defmodule MathTest do
  use ExUnit.Case

  # Test with assertion
  test "add/2 adds two numbers" do
    assert Math.add(2, 3) == 5
    assert Math.add(-1, 1) == 0
  end

  # Refute (opposite of assert)
  test "add/2 does not return incorrect sum" do
    refute Math.add(2, 3) == 6
  end

  # Pattern matching in assertions
  test "divide/2 returns ok tuple" do
    assert {:ok, result} = Math.divide(10, 2)
    assert result == 5
  end

  # Testing errors
  test "divide/2 returns error on division by zero" do
    assert {:error, :division_by_zero} = Math.divide(10, 0)
  end
end

Test Lifecycle

defmodule UserTest do
  use ExUnit.Case

  # Setup runs before each test
  setup do
    user = %User{name: "Alice", age: 30}
    {:ok, user: user}
  end

  # Access setup data via context
  test "user has name", %{user: user} do
    assert user.name == "Alice"
  end

  # Setup with explicit context
  setup context do
    if context[:admin] do
      {:ok, user: %User{name: "Admin", role: :admin}}
    else
      :ok
    end
  end

  @tag :admin
  test "admin user has correct role", %{user: user} do
    assert user.role == :admin
  end

  # Setup all (runs once before all tests)
  setup_all do
    # Start database connection
    {:ok, conn} = Database.connect()
    on_exit(fn -> Database.disconnect(conn) end)
    {:ok, conn: conn}
  end
end

Assertions

# Equality
assert 1 + 1 == 2
refute 1 + 1 == 3

# Pattern matching
assert {:ok, value} = function_that_returns_tuple()
assert %User{name: name} = get_user()

# Boolean
assert is_binary("hello")
assert is_list([1, 2, 3])

# Membership
assert 3 in [1, 2, 3]
refute 4 in [1, 2, 3]

# Approximate equality (floats)
assert_in_delta 0.1 + 0.2, 0.3, 0.0001

# Exception testing
assert_raise ArithmeticException, fn ->
  1 / 0
end

assert_raise ArgumentError, "invalid argument", fn ->
  raise ArgumentError, "invalid argument"
end

# Receive message testing
send(self(), {:hello, "world"})
assert_receive {:hello, msg}
assert msg == "world"

# No message received
refute_receive {:unexpected, _}, 100

Doctests

defmodule Math do
  @doc """
  Adds two numbers together.

  ## Examples

      iex> Math.add(2, 3)
      5

      iex> Math.add(-1, 1)
      0

      iex> Math.add(0.1, 0.2)
      0.30000000000000004
  """
  def add(a, b), do: a + b

  @doc """
  Divides two numbers.

  ## Examples

      iex> Math.divide(10, 2)
      {:ok, 5.0}

      iex> Math.divide(10, 0)
      {:error, :division_by_zero}
  """
  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(a, b), do: {:ok, a / b}
end

# In test file, enable doctests
defmodule MathTest do
  use ExUnit.Case
  doctest Math
end

Async Tests

# Run tests asynchronously (safe if no shared state)
defmodule FastTest do
  use ExUnit.Case, async: true

  test "independent test 1" do
    assert 1 + 1 == 2
  end

  test "independent test 2" do
    assert 2 * 2 == 4
  end
end

# Synchronous tests (default, for shared resources)
defmodule DatabaseTest do
  use ExUnit.Case  # async: false is default

  test "writes to database" do
    # Safe to share database
  end
end

Test Tags and Filtering

defmodule UserTest do
  use ExUnit.Case

  # Tag individual test
  @tag :slow
  test "slow integration test" do
    # ...
  end

  # Tag with value
  @tag timeout: 5000
  test "test with custom timeout" do
    # ...
  end

  # Multiple tags
  @tag :integration
  @tag :database
  test "database integration" do
    # ...
  end

  # Module-level tags (apply to all tests)
  @moduletag :integration
end
# Run only tagged tests
mix test --only slow
mix test --only integration

# Exclude tagged tests
mix test --exclude slow
mix test --exclude integration:database

# Include by default excluded tests
mix test --include pending

Mocking with Mox

# Define behaviour
defmodule WeatherAPI do
  @callback get_temperature(city :: String.t()) :: {:ok, float()} | {:error, term()}
end

# Define mock in test_helper.exs
Mox.defmock(WeatherAPIMock, for: WeatherAPI)

# In your module, inject dependency
defmodule WeatherService do
  def get_weather(city, api \\ WeatherAPI) do
    case api.get_temperature(city) do
      {:ok, temp} -> "Temperature in #{city}: #{temp}°C"
      {:error, _} -> "Could not fetch weather"
    end
  end
end

# In test
defmodule WeatherServiceTest do
  use ExUnit.Case, async: true
  import Mox

  # Set up expectations
  setup :verify_on_exit!

  test "returns temperature message" do
    expect(WeatherAPIMock, :get_temperature, fn "NYC" ->
      {:ok, 25.0}
    end)

    result = WeatherService.get_weather("NYC", WeatherAPIMock)
    assert result == "Temperature in NYC: 25.0°C"
  end

  test "handles API errors" do
    expect(WeatherAPIMock, :get_temperature, fn _city ->
      {:error, :timeout}
    end)

    result = WeatherService.get_weather("NYC", WeatherAPIMock)
    assert result == "Could not fetch weather"
  end

  test "allows multiple calls" do
    stub(WeatherAPIMock, :get_temperature, fn _city ->
      {:ok, 20.0}
    end)

    WeatherService.get_weather("NYC", WeatherAPIMock)
    WeatherService.get_weather("SF", WeatherAPIMock)
    # Both calls succeed with stub
  end
end

Property-Based Testing with StreamData

# Add to mix.exs
defp deps do
  [
    {:stream_data, "~> 0.6", only: :test}
  ]
end

# Property-based test
defmodule StringPropertiesTest do
  use ExUnit.Case
  use ExUnitProperties

  property "reversing a string twice returns original" do
    check all string <- string(:printable) do
      reversed_twice = string |> String.reverse() |> String.reverse()
      assert reversed_twice == string
    end
  end

  property "list concatenation is associative" do
    check all list1 <- list_of(integer()),
              list2 <- list_of(integer()),
              list3 <- list_of(integer()) do
      assert (list1 ++ list2) ++ list3 == list1 ++ (list2 ++ list3)
    end
  end

  property "sorting is idempotent" do
    check all list <- list_of(integer()) do
      sorted_once = Enum.sort(list)
      sorted_twice = Enum.sort(sorted_once)
      assert sorted_once == sorted_twice
    end
  end

  # Custom generator
  property "user age is always positive" do
    check all name <- string(:alphanumeric),
              age <- positive_integer() do
      user = %User{name: name, age: age}
      assert User.valid?(user)
    end
  end
end

Testing Processes and GenServers

defmodule CounterTest do
  use ExUnit.Case

  test "counter increments" do
    {:ok, pid} = Counter.start_link(0)

    Counter.increment(pid)
    Counter.increment(pid)

    assert Counter.get(pid) == 2
  end

  test "counter handles cast" do
    {:ok, pid} = Counter.start_link(0)

    Counter.async_increment(pid)
    # Give it time to process cast
    Process.sleep(10)

    assert Counter.get(pid) == 1
  end

  test "counter can be supervised" do
    children = [
      {Counter, 0}
    ]

    {:ok, supervisor} = Supervisor.start_link(children, strategy: :one_for_one)

    # Get counter pid from supervisor
    [{Counter, pid, _, _}] = Supervisor.which_children(supervisor)

    Counter.increment(pid)
    assert Counter.get(pid) == 1

    # Clean up
    Supervisor.stop(supervisor)
  end
end

Testing with ExUnit.CaptureIO

import ExUnit.CaptureIO

test "prints greeting to stdout" do
  output = capture_io(fn ->
    IO.puts("Hello, World!")
  end)

  assert output == "Hello, World!\n"
end

test "captures user input" do
  result = capture_io("Alice\n", fn ->
    name = IO.gets("Enter name: ")
    String.trim(name)
  end)

  assert result == "Alice"
end

test "captures stderr" do
  output = capture_io(:stderr, fn ->
    IO.warn("Warning message")
  end)

  assert output =~ "Warning message"
end

Common Testing Patterns

# Test with multiple assertions using pipe
test "user creation pipeline" do
  params = %{name: "Alice", email: "alice@example.com"}

  assert {:ok, user} =
    params
    |> User.changeset()
    |> Repo.insert()

  assert user.name == "Alice"
  assert user.email == "alice@example.com"
end

# Test with pattern matching and guards
test "validates positive numbers" do
  assert {:ok, result} = Math.sqrt(4)
  assert result == 2.0

  assert {:error, :negative_number} = Math.sqrt(-1)
end

# Test with describe blocks for organization
describe "User.create/1" do
  test "creates user with valid params" do
    # ...
  end

  test "returns error with invalid email" do
    # ...
  end

  test "returns error with duplicate email" do
    # ...
  end
end

# Test with shared setup using tags
setup context do
  case context[:user_type] do
    :admin -> {:ok, user: create_admin_user()}
    :regular -> {:ok, user: create_regular_user()}
    _ -> :ok
  end
end

@tag user_type: :admin
test "admin can delete users", %{user: admin} do
  assert admin.role == :admin
end

Metaprogramming

Elixir provides powerful metaprogramming capabilities through macros, which operate on the Abstract Syntax Tree (AST) at compile time.

Quote and Unquote

# quote turns code into AST representation
quote do
  1 + 2
end
# {:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [1, 2]}

# unquote injects values into quoted expressions
defmodule Example do
  x = 10
  quoted = quote do
    unquote(x) + 5
  end
  # {+, _, [10, 5]}
end

# unquote_splicing for lists
args = [1, 2, 3]
quote do
  sum(unquote_splicing(args))
end
# {:sum, [], [1, 2, 3]}

Defining Macros

defmodule MyMacros do
  # Basic macro
  defmacro say(expression) do
    quote do
      IO.puts(unquote(expression))
    end
  end

  # Macro with variable hygiene
  defmacro double(x) do
    quote do
      result = unquote(x)
      result * 2
    end
  end

  # Debugging macro - shows expression and result
  defmacro debug(expression) do
    quote bind_quoted: [expr: expression] do
      IO.inspect(expr, label: unquote(Macro.to_string(expression)))
    end
  end
end

# Usage
require MyMacros
MyMacros.say("Hello!")
MyMacros.debug(1 + 2)  # 1 + 2: 3

The using Macro

defmodule MyBehaviour do
  # __using__ is called when `use MyBehaviour` is invoked
  defmacro __using__(opts) do
    quote do
      import MyBehaviour
      @behaviour MyBehaviour

      # Inject default implementations
      def default_name, do: unquote(opts[:name] || "Unknown")

      # Allow override
      defoverridable default_name: 0
    end
  end

  @callback required_callback() :: term()
end

# Usage
defmodule MyModule do
  use MyBehaviour, name: "Custom"
end

AST Manipulation

# Traverse and transform AST
defmodule ASTHelper do
  def transform(ast) do
    Macro.prewalk(ast, fn
      {:+, meta, [left, right]} ->
        {:-, meta, [left, right]}  # Replace + with -
      node ->
        node
    end)
  end

  # Expand macros in AST
  def expand_all(ast, env) do
    Macro.expand(ast, env)
  end

  # Convert AST to string
  def to_string(ast) do
    Macro.to_string(ast)
  end
end

# Inspect AST structure
quote do: if(true, do: 1, else: 2)
|> Macro.to_string()
# "if(true, do: 1, else: 2)"

Compile-Time Code Generation

defmodule Router do
  # Generate functions at compile time from data
  @routes [
    {:get, "/", :index},
    {:get, "/users", :users},
    {:post, "/users", :create_user}
  ]

  for {method, path, handler} <- @routes do
    def route(unquote(method), unquote(path)) do
      apply(__MODULE__, unquote(handler), [])
    end
  end

  def index, do: "Home page"
  def users, do: "List users"
  def create_user, do: "Create user"
end

# Also useful: Module.register_attribute/3 for accumulating data
defmodule PluginHost do
  Module.register_attribute(__MODULE__, :plugins, accumulate: true)

  @plugins :auth
  @plugins :logging
  @plugins :caching

  def plugins, do: @plugins  # [:caching, :logging, :auth]
end

Serialization

Elixir uses Jason (or Poison) for JSON serialization and Protocol-based encoding for custom types.

Jason (Recommended)

# Add to mix.exs: {:jason, "~> 1.4"}

# Encoding
Jason.encode!(%{name: "Alice", age: 30})
# "{\"name\":\"Alice\",\"age\":30}"

# Decoding
Jason.decode!("{\"name\":\"Alice\",\"age\":30}")
# %{"name" => "Alice", "age" => 30}

# With atom keys
Jason.decode!("{\"name\":\"Alice\"}", keys: :atoms)
# %{name: "Alice"}

# Pretty printing
Jason.encode!(%{user: %{name: "Alice"}}, pretty: true)

Implementing Jason.Encoder Protocol

defmodule User do
  @derive {Jason.Encoder, only: [:id, :name, :email]}
  defstruct [:id, :name, :email, :password_hash]
end

# Custom encoder implementation
defmodule Money do
  defstruct [:amount, :currency]
end

defimpl Jason.Encoder, for: Money do
  def encode(%Money{amount: amount, currency: currency}, opts) do
    Jason.Encode.string("#{currency} #{amount}", opts)
  end
end

# Usage
Jason.encode!(%Money{amount: 100, currency: "USD"})
# "\"USD 100\""

Poison (Alternative)

# Add to mix.exs: {:poison, "~> 5.0"}

Poison.encode!(%{name: "Alice"})
Poison.decode!("{\"name\":\"Alice\"}")

# Implementing Poison.Encoder
defimpl Poison.Encoder, for: DateTime do
  def encode(datetime, options) do
    Poison.Encoder.BitString.encode(DateTime.to_iso8601(datetime), options)
  end
end

Ecto Changesets for Validation

defmodule User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    field :email, :string
    field :age, :integer
    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :email, :age])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> validate_number(:age, greater_than: 0)
  end
end

# Validate incoming JSON
params = Jason.decode!(json_string)
changeset = User.changeset(%User{}, params)

if changeset.valid? do
  {:ok, Ecto.Changeset.apply_changes(changeset)}
else
  {:error, changeset.errors}
end

Term Serialization

# Erlang Term Format (binary)
binary = :erlang.term_to_binary(%{key: "value", list: [1, 2, 3]})
:erlang.binary_to_term(binary)

# External Term Format (for distributed systems)
binary = :erlang.term_to_binary(data, [:compressed])

# Safe deserialization (atoms must exist)
:erlang.binary_to_term(binary, [:safe])

REPL and Development Workflow

IEx (Interactive Elixir) is central to Elixir development, providing a powerful REPL with debugging, introspection, and hot code reloading.

Starting IEx

# Basic IEx
iex

# With Mix project loaded
iex -S mix

# With Phoenix server
iex -S mix phx.server

# With custom configuration
iex --dot-iex path/to/.iex.exs -S mix

IEx Helpers

# In IEx session:

# Help and documentation
h Enum.map/2           # Function docs
h Enum                 # Module docs
t Enum.t()             # Type specs

# Code inspection
i [1, 2, 3]            # Inspect value
i Enum                 # Inspect module

# Compilation
c "path/to/file.ex"    # Compile file
r MyModule             # Recompile module
recompile()            # Recompile project

# Value history
v()                    # Last result
v(1)                   # First result
v(-1)                  # Previous result

# Shell commands
pwd()                  # Current directory
ls()                   # List files
cd("path")             # Change directory

IEx.pry for Debugging

defmodule MyModule do
  def process(data) do
    transformed = transform(data)

    # Insert breakpoint
    require IEx; IEx.pry()

    finalize(transformed)
  end
end

# When code hits pry:
# - Inspect local variables: transformed, data
# - Call functions: transform(other_data)
# - Continue: respawn() or Ctrl+C twice

Hot Code Reloading

# Recompile and reload module in IEx
recompile()

# Reload specific module
r MyModule

# For Phoenix - automatic in dev mode
# Code changes trigger recompilation on next request

# In production (Distillery/Release)
# Use hot code upgrades via :code.load_file/1
:code.purge(MyModule)
:code.load_file(MyModule)

Observer for System Inspection

# Start Observer (GUI)
:observer.start()

# Observer shows:
# - System overview (memory, CPU, processes)
# - Process list with message queues
# - Application supervision trees
# - ETS tables
# - Port info

# For remote nodes
Node.connect(:"app@hostname")
:observer.start()
# Then select remote node in Nodes menu

.iex.exs Configuration

# In ~/.iex.exs or project .iex.exs

# Custom aliases
alias MyApp.{Repo, User, Account}

# Import helpers
import Ecto.Query

# Custom helpers
defmodule H do
  def reload do
    IEx.Helpers.recompile()
    IO.puts("Reloaded!")
  end

  def user(id), do: Repo.get(User, id)
end

# Configure IEx
IEx.configure(
  colors: [enabled: true],
  history_size: 100,
  inspect: [limit: :infinity]
)

Runtime Debugging

# Trace function calls
:dbg.tracer()
:dbg.p(:all, :c)
:dbg.tp(MyModule, :my_function, :x)
# Now calls to MyModule.my_function will be traced

# Stop tracing
:dbg.stop()

# Using :recon for production debugging
# Add {:recon, "~> 2.5"} to mix.exs
:recon.proc_count(:memory, 10)      # Top 10 by memory
:recon.proc_count(:message_queue_len, 10)  # Top 10 by queue
:recon.bin_leak(5)                  # Binary memory leaks

Zero and Default Values

Elixir handles absence of values through nil and pattern matching, with explicit default handling patterns.

Nil Handling

# nil is a valid value (not an error)
user = nil
is_nil(user)  # true

# Nil-safe access with pattern matching
case get_user(id) do
  nil -> {:error, :not_found}
  user -> {:ok, user}
end

# Nil coalescing with ||
name = user_name || "Anonymous"

# Access with default
Map.get(map, :key, "default")
Keyword.get(opts, :timeout, 5000)

# Nil-safe navigation (no ?. operator, use pattern matching)
defp get_city(user) do
  case user do
    %{address: %{city: city}} -> city
    _ -> nil
  end
end

# Or with get_in
get_in(user, [:address, :city])

Default Function Arguments

defmodule Config do
  # Default arguments
  def connect(host, port \\ 80, opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 5000)
    ssl = Keyword.get(opts, :ssl, false)
    {host, port, timeout, ssl}
  end
end

# Multiple clauses with defaults
Config.connect("localhost")           # port=80, opts=[]
Config.connect("localhost", 443)      # opts=[]
Config.connect("localhost", 443, ssl: true)

Struct Defaults

defmodule User do
  # All fields with defaults
  defstruct name: "Unknown",
            email: nil,
            role: :user,
            active: true,
            metadata: %{}

  # Enforce required fields
  @enforce_keys [:email]
  defstruct [:email, name: "Unknown", role: :user]
end

# Creating with defaults
%User{email: "test@example.com"}
# %User{email: "test@example.com", name: "Unknown", role: :user}

# Pattern match with defaults
def greet(%User{name: name}) do
  "Hello, #{name}!"
end

Default Values in Maps

# Access with default
map = %{a: 1, b: 2}
Map.get(map, :c, 0)  # 0

# Update with default
Map.update(map, :c, 0, &(&1 + 1))  # %{a: 1, b: 2, c: 0}

# get_and_update with default
Map.get_and_update(map, :c, fn
  nil -> {nil, 0}
  val -> {val, val + 1}
end)

# Merge with defaults
defaults = %{timeout: 5000, retries: 3}
config = %{timeout: 10000}
Map.merge(defaults, config)
# %{timeout: 10000, retries: 3}

With Construct for Nil Propagation

# Chain operations that may return nil
def process_order(order_id) do
  with {:ok, order} <- fetch_order(order_id),
       {:ok, user} <- fetch_user(order.user_id),
       {:ok, payment} <- process_payment(user, order) do
    {:ok, %{order: order, user: user, payment: payment}}
  else
    nil -> {:error, :not_found}
    {:error, reason} -> {:error, reason}
  end
end

# Helper for nil-returning functions
defp fetch_order(id) do
  case Repo.get(Order, id) do
    nil -> {:error, :order_not_found}
    order -> {:ok, order}
  end
end

Default Behaviours

defmodule Cache do
  @callback get(key :: term()) :: {:ok, term()} | :error
  @callback put(key :: term(), value :: term()) :: :ok

  # Optional callback with default
  @callback ttl() :: integer()
  @optional_callbacks ttl: 0

  defmacro __using__(_opts) do
    quote do
      @behaviour Cache

      # Default implementation
      def ttl, do: 3600

      defoverridable ttl: 0
    end
  end
end

defmodule MyCache do
  use Cache

  def get(key), do: # ...
  def put(key, value), do: # ...
  # ttl/0 uses default of 3600
end

Cross-Cutting Patterns

For cross-language comparison and translation patterns, see:

  • patterns-concurrency-dev - Processes, GenServers, supervision trees
  • patterns-serialization-dev - JSON encoding/decoding, protocols
  • patterns-metaprogramming-dev - Macros, compile-time code generation
  • patterns-testing-dev - Testing strategies, property-based testing

References