🎓 A1Learner
🔍
0 of 46 lessons completed

Elixir Compile-Time Validation

Compile-time validation is checking a value while code compiles, so a bad value stops the build. A macro does the check because it sees the literal value before any code runs.

A macro can allow a good value and reject a bad one

The good port prints. The bad port is compiled from text, and its fixed message prints instead of stopping the script.

defmodule Validator do
  defmacro port(number) do
    if not is_integer(number) or number < 1 or number > 65535 do
      raise ArgumentError, message: "port out of range"
    end
    quote do
      unquote(number)
    end
  end
end

defmodule Caller do
  require Validator
  def value, do: Validator.port(8080)
end

IO.puts(Caller.value())
code = "defmodule Bad do\n  require Validator\n  def value, do: Validator.port(70000)\nend"
try do
  Code.compile_string(code)
  IO.puts("compiled")
rescue
  e -> IO.puts(Exception.message(e))
end

A macro can allow only named values

Green sits in the allowed list, so the caller prints it. A name outside the list would raise during compilation.

defmodule Checker do
  defmacro allow(name) do
    if name not in [:red, :green] do
      raise ArgumentError, message: "colour not allowed"
    end
    quote do
      unquote(name)
    end
  end
end

defmodule Caller do
  require Checker
  def colour, do: Checker.allow(:green)
end

IO.inspect(Caller.colour())

A macro can check a route and keep its shape

The route method and path pass their checks. Escaping keeps the three-part route whole when it is inserted.

defmodule RouteCheck do
  defmacro route(method, path, handler) do
    if method not in [:get, :post] do
      raise ArgumentError, message: "unknown method"
    end
    if not is_binary(path) or not String.starts_with?(path, "/") do
      raise ArgumentError, message: "bad path"
    end
    tuple = {method, path, handler}
    quote do
      unquote(Macro.escape(tuple))
    end
  end
end

defmodule Caller do
  require RouteCheck
  def home, do: RouteCheck.route(:get, "/home", :home)
end

IO.inspect(Caller.home())

Example

defmodule RouteCheck do
  defmacro route(method, path, handler) do
    if method not in [:get, :post] do
      raise ArgumentError, message: "unknown method"
    end
    if not is_binary(path) or not String.starts_with?(path, "/") do
      raise ArgumentError, message: "bad path"
    end
    tuple = {method, path, handler}
    quote do
      unquote(Macro.escape(tuple))
    end
  end
end

defmodule Caller do
  require RouteCheck
  def home, do: RouteCheck.route(:get, "/home", :home)
  def save, do: RouteCheck.route(:post, "/save", :save)
end

IO.inspect(Caller.home())
IO.inspect(Caller.save())
code = "defmodule BadRoute do\n  require RouteCheck\n  def bad, do: RouteCheck.route(:delete, \"/gone\", :gone)\nend"
try do
  Code.compile_string(code)
  IO.puts("compiled")
rescue
  e -> IO.puts(Exception.message(e))
end

Key points

  • Literal: a literal is a value written directly in code, such as 8080 or :green.
  • Check in macro: a macro can test a literal value and raise an error when the value breaks a rule.
  • Raise: raising stops compilation with a fixed message that names the broken rule.
  • Route: a route is a three-part value holding a method, a path, and a handler name.
  • Escape: Macro.escape turns a three-part route into a safe tree form before it is inserted.

In this example, two good routes print in full, and a bad route compiled from text prints its fixed message instead of stopping the script.

Note

only literal values can be checked this way, because a macro cannot see a value that is computed at run time.

Try it Yourself

Edit the code, then press Run.

Running is simulated

Exercise

What happens when a macro check fails on a literal value?