Elixir: Macros & Metaprogramming

Elixir code is data: every expression is a three-element tuple called a quoted form, and a macro is a function that receives quoted code and returns quoted code, executed at compile time. This is how 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.

KeywordWhat it doesExample
importbrings functions/macros into the current scopeimport Enum, only: [map: 2]
requireguarantees macros are available (no functions)require Logger
usecalls the module's __using__/1 macro — injects arbitrary codeuse 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/1 carefully 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

  1. Print the AST of 1 + 2 * 3 with quote and identify the three tuple elements.
  2. Extend Assert.assert!/1 into assert_eq!/2 that reports both sides on failure.
  3. Build a Traced consumer module and confirm @traced exists in iex via Worker.@traced — then explain why require was needed.

Next: Mix, Dependencies & Releases