Macros
Need to generate code at compile time? Elixir macros enable powerful metaprogramming with AST manipulation, allowing you to extend the language and create domain-specific languages.
Prerequisites
- Strong understanding of Elixir syntax
- Familiarity with AST (Abstract Syntax Trees)
- Knowledge of quote and unquote
- Completed Advanced Tutorial
Problem
You want to generate repetitive code, create DSLs, or perform compile-time transformations. Writing boilerplate manually is error-prone and hard to maintain.
Challenges:
- Understanding AST structure and manipulation
- Knowing when to use macros vs. functions
- Managing hygiene and variable scope
- Debugging macro-generated code
- Avoiding macro over-engineering
Solution
Use macros to write code that writes code, transforming AST at compile time.
How It Works
1. Basic Macro with quote/unquote
defmodule MyMacros do
defmacro say_hello(name) do
quote do
IO.puts("Hello, #{unquote(name)}!")
end
end
end
require MyMacros
MyMacros.say_hello("World") # Prints: Hello, World!How it works:
quotecaptures the code as ASTunquoteinjects the value into the AST- The AST is injected into the caller’s context at compile time
2. Understanding AST
quote do: 1 + 2
quote do: foo(a, b)
{function_name, metadata, arguments}3. Macro with Pattern Matching
defmodule Assertions do
defmacro assert({:==, _, [left, right]}) do
quote do
left_val = unquote(left)
right_val = unquote(right)
unless left_val == right_val do
raise "Assertion failed: #{inspect(left_val)} != #{inspect(right_val)}"
end
end
end
end
require Assertions
Assertions.assert(1 + 1 == 2) # Passes
Assertions.assert(1 + 1 == 3) # Raises error4. Creating a DSL
defmodule Router do
defmacro __using__(_opts) do
quote do
import Router
@routes []
@before_compile Router
end
end
defmacro get(path, handler) do
quote do
@routes [{:get, unquote(path), unquote(handler)} | @routes]
end
end
defmacro post(path, handler) do
quote do
@routes [{:post, unquote(path), unquote(handler)} | @routes]
end
end
defmacro __before_compile__(_env) do
quote do
def routes, do: Enum.reverse(@routes)
def match(method, path) do
Enum.find_value(routes(), fn
{^method, ^path, handler} -> {:ok, handler}
_ -> nil
end) || {:error, :not_found}
end
end
end
end
defmodule MyRouter do
use Router
get "/users", &list_users/0
get "/users/:id", &show_user/1
post "/users", &create_user/1
end
MyRouter.routes()Advanced Patterns
1. Module Attributes in Macros
defmodule Schema do
defmacro __using__(_opts) do
quote do
import Schema
Module.register_attribute(__MODULE__, :fields, accumulate: true)
@before_compile Schema
end
end
defmacro field(name, type) do
quote do
@fields {unquote(name), unquote(type)}
end
end
defmacro __before_compile__(_env) do
quote do
def fields, do: Enum.reverse(@fields)
def new(attrs \\ %{}) do
struct(__MODULE__, attrs)
end
end
end
end
defmodule User do
use Schema
defstruct [:name, :email, :age]
field :name, :string
field :email, :string
field :age, :integer
end
User.fields()2. Hygiene and Variable Scoping
defmodule Hygienic do
defmacro safe do
quote do
x = 42
x * 2
end
end
end
x = 1
require Hygienic
Hygienic.safe() # 84
x # Still 1 - not affected by macro
defmodule Unhygienic do
defmacro unsafe do
quote do
var!(x) = 42 # var! bypasses hygiene
end
end
end
x = 1
require Unhygienic
Unhygienic.unsafe()
x # Now 42 - affected by macro3. Context-Aware Macros
defmodule Contextual do
defmacro log(message) do
quote do
IO.puts("[#{__MODULE__}:#{__ENV__.line}] #{unquote(message)}")
end
end
end
defmodule MyApp do
require Contextual
def work do
Contextual.log("Starting work") # [MyApp:5] Starting work
end
end4. Generating Functions Dynamically
defmodule RESTClient do
@methods [:get, :post, :put, :delete, :patch]
for method <- @methods do
def unquote(method)(url, opts \\ []) do
HTTPoison.request(unquote(method), url, "", [], opts)
end
end
end
RESTClient.get("https://api.example.com/users")
RESTClient.post("https://api.example.com/users", body: "...")Real-World Examples
1. Test Assertions DSL
defmodule TestDSL do
defmacro test(description, do: block) do
test_name = String.to_atom("test_#{description}")
quote do
def unquote(test_name)() do
unquote(block)
end
end
end
defmacro assert_equal(left, right) do
quote do
left_val = unquote(left)
right_val = unquote(right)
if left_val != right_val do
raise """
Assertion failed:
Expected: #{inspect(right_val)}
Got: #{inspect(left_val)}
"""
end
end
end
end
defmodule MathTest do
import TestDSL
test "addition" do
assert_equal 1 + 1, 2
assert_equal 2 + 2, 4
end
test "multiplication" do
assert_equal 2 * 3, 6
assert_equal 5 * 5, 25
end
end2. Validation DSL
defmodule Validator do
defmacro __using__(_opts) do
quote do
import Validator
@validations []
@before_compile Validator
end
end
defmacro validates(field, rules) do
quote do
@validations [{unquote(field), unquote(rules)} | @validations]
end
end
defmacro __before_compile__(_env) do
quote do
def validate(data) do
Enum.reduce_while(@validations, {:ok, data}, fn {field, rules}, {:ok, data} ->
value = Map.get(data, field)
case validate_field(value, rules) do
:ok -> {:cont, {:ok, data}}
{:error, reason} -> {:halt, {:error, {field, reason}}}
end
end)
end
defp validate_field(value, rules) do
Enum.reduce_while(rules, :ok, fn
{:required, true}, :ok ->
if value in [nil, ""], do: {:halt, {:error, "is required"}}, else: {:cont, :ok}
{:min_length, min}, :ok when is_binary(value) ->
if String.length(value) < min,
do: {:halt, {:error, "must be at least #{min} characters"}},
else: {:cont, :ok}
{:format, regex}, :ok when is_binary(value) ->
if Regex.match?(regex, value),
do: {:cont, :ok},
else: {:halt, {:error, "invalid format"}}
_, :ok ->
{:cont, :ok}
end)
end
end
end
end
defmodule UserValidator do
use Validator
validates :name, required: true, min_length: 2
validates :email, required: true, format: ~r/@/
validates :age, required: true
end
UserValidator.validate(%{name: "Al", email: "al@example.com", age: 30})
UserValidator.validate(%{name: "", email: "invalid", age: 30})3. Pipeline Debugging Macro
defmodule Pipeline do
defmacro debug(pipeline) do
quote do
result = unquote(pipeline)
IO.puts("Pipeline result: #{inspect(result)}")
result
end
end
defmacro trace(pipeline) do
# Extract steps from pipeline
steps = extract_pipeline_steps(pipeline)
debug_steps =
Enum.map(steps, fn step ->
quote do
result = unquote(step)
IO.puts(" #{unquote(Macro.to_string(step))} => #{inspect(result)}")
result
end
end)
quote do
IO.puts("Pipeline trace:")
unquote_splicing(debug_steps)
end
end
defp extract_pipeline_steps({:|>, _, [left, right]}) do
extract_pipeline_steps(left) ++ [right]
end
defp extract_pipeline_steps(expr), do: [expr]
end
require Pipeline
Pipeline.trace(
[1, 2, 3]
|> Enum.map(&(&1 * 2))
|> Enum.filter(&(&1 > 3))
|> Enum.sum()
)4. Configuration DSL
defmodule Config do
defmacro __using__(_opts) do
quote do
import Config
@config %{}
@before_compile Config
end
end
defmacro set(key, value) do
quote do
@config Map.put(@config, unquote(key), unquote(value))
end
end
defmacro env(key, env_var, default \\ nil) do
quote do
value = System.get_env(unquote(env_var)) || unquote(default)
@config Map.put(@config, unquote(key), value)
end
end
defmacro __before_compile__(_env) do
quote do
def config, do: @config
def get(key, default \\ nil), do: Map.get(@config, key, default)
end
end
end
defmodule AppConfig do
use Config
set :app_name, "MyApp"
set :version, "1.0.0"
env :database_url, "DATABASE_URL", "postgres://localhost/myapp"
env :secret_key, "SECRET_KEY"
end
AppConfig.get(:app_name) # "MyApp"
AppConfig.get(:database_url) # From env or defaultCommon Pitfalls
1. Using Macros When Functions Suffice
Problem:
defmacro add(a, b) do
quote do
unquote(a) + unquote(b)
end
endSolution:
def add(a, b), do: a + bRule: Use macros only when you need compile-time code generation. Functions are simpler and easier to debug.
2. Variable Hygiene Issues
defmodule Broken do
defmacro set_x do
quote do
x = 42 # Might conflict with caller's x
end
end
end
defmodule Fixed do
defmacro set_x do
quote do
unquote(Macro.var(:x, __MODULE__)) = 42
end
end
end3. Not Understanding AST
defmacro broken(list) do
quote do
Enum.map(unquote(list), &(&1 * 2)) # list isn't available at compile time
end
end
defmacro fixed(list) do
quote do
unquote(list) |> Enum.map(&(&1 * 2))
end
endDebugging Macros
1. Inspecting Macro Expansion
ast = quote do: MyMacro.my_macro(42)
Macro.expand(ast, __ENV__)2. Using dbg in Macros
defmacro debug_macro(expr) do
IO.puts("Macro input AST: #{inspect(expr)}")
result = quote do
unquote(expr)
end
IO.puts("Macro output AST: #{inspect(result)}")
result
end3. Testing Macros
defmodule MacroTest do
use ExUnit.Case
require MyMacros
test "macro expands correctly" do
ast = quote do: MyMacros.my_macro(42)
expanded = Macro.expand(ast, __ENV__)
# Assert on the expanded AST structure
assert match?({:def, _, _}, expanded)
end
test "macro generates correct code" do
# Test the runtime behavior
assert MyMacros.my_macro(42) == expected_result
end
endPerformance Considerations
Compile-Time vs. Runtime
defmacro compile_time_work(n) do
result = expensive_computation(n) # Runs once at compile time
quote do: unquote(result)
end
def runtime_work(n) do
expensive_computation(n) # Runs every call
endWhen to Use Macros
Good Use Cases:
- DSLs (routing, testing, configuration)
- Code generation (repeated patterns)
- Compile-time optimization
- Extending language syntax
Avoid For:
- Simple data transformation (use functions)
- Runtime logic (use functions)
- When debugging simplicity matters
- When team unfamiliar with metaprogramming
Macro Best Practices
- Prefer functions over macros - Only use macros when necessary
- Document macro behavior - Explain what code they generate
- Keep macros simple - Complex macros are hard to maintain
- Test macro expansion - Verify AST generation
- Respect hygiene - Avoid variable leakage
- Provide escape hatches - Allow users to opt-out of macro magic
- Use @before_compile wisely - For final module modifications
Related Resources
Last updated