Elixir: Macros & Metaprogramming
defmodule, PIPE-style DSLs, and Phoenix routes work — and it
is also the sharpest tool in the language, to be used deliberately.
The AST: quote
# quote returns the AST of the expression instead of evaluating it.
quote do: 1 + 2
#=> {:+, [context: Elixir, imports: [{2, Kernel}]], [1, 2]}
# ^call ^metadata (line, context) ^arguments
# Every AST node is {form, metadata, args}. Values quote to themselves:
quote do: :ok #=> :ok
quote do: [1, 2] #=> [1, 2] (literal lists stay literal)
# Inspect an AST as CODE again — great for learning:
Macro.to_string(quote do: 1 + 2) #=> "1 + 2"
unquote and defmacro
unquote/1 splices a runtime value into a quoted form. A macro's inputs are
quoted expressions and its output is spliced into the caller's code — at compile time.
defmodule Assert do
# Macros must be explicitly required before use.
@doc "Fails compilation-time test with a descriptive message."
defmacro assert!(expr) do
# unquote injects the caller's expression into our generated code.
quote do
case unquote(expr) do
true -> :ok
false -> raise "assertion failed: #{unquote(Macro.to_string(expr))}"
end
end
end
end
defmodule Demo do
require Assert # bring the macro into this module
Assert.assert!(1 + 1 == 2) # expanded at COMPILE time
end
use, import and require
Three keywords control code reuse, and they do different things — mixing them up is a common beginner error.
| Keyword | What it does | Example |
|---|---|---|
import | brings functions/macros into the current scope | import Enum, only: [map: 2] |
require | guarantees macros are available (no functions) | require Logger |
use | calls the module's __using__/1 macro — injects arbitrary code | use GenServer |
defmodule Traced do
# __using__ runs when someone writes `use Traced` — the module can
# then inject imports, attributes, or whole functions.
defmacro __using__(_opts) do
quote do
import Traced, only: [trace: 1]
# Injects a start-time attribute into the consuming module.
@traced true
end
end
defmacro trace(expr) do
quote do
result = unquote(expr)
IO.puts("#{unquote(Macro.to_string(expr))} => #{inspect(result)}")
result
end
end
end
defmodule Worker do
use Traced # gains trace/1 and @traced
def compute, do: trace(40 + 2) # prints "40 + 2 => 42", returns 42
end
Macro Hygiene
Macros are hygienic by default: variables created inside quote do not leak into the
caller's scope. Use var!(name) when you deliberately want to touch the caller's
binding — and treat that as a code smell. Keep the generated code small, document it, and prefer
defdelegate or plain functions when a macro adds nothing.
When NOT to Write a Macro
- A function would work — macros lose stack traces, stepping, and editor support.
- You need runtime values — macros see code, not data; they run before any value exists.
- The AST is complicated — use
Macro.escape/1carefully when injecting values into quoted code.
The professional pattern: reach for macros only to remove repeated structural boilerplate (declarative routes, validations, tracing), and keep the heavy lifting in functions.
Practice
- Print the AST of
1 + 2 * 3withquoteand identify the three tuple elements. - Extend
Assert.assert!/1intoassert_eq!/2that reports both sides on failure. - Build a
Tracedconsumer module and confirm@tracedexists iniexviaWorker.@traced— then explain whyrequirewas needed.