Using Guard Clauses

Guard Clauses: Adding Conditions to Your Matches

Welcome back. In the previous lesson, you used multiple function clauses to match different input shapes right in the function head. As a reminder, we also briefly used a guard to prevent dividing by zero. In this lesson, you will focus on guard clauses themselves — how to attach conditions to a clause so it runs only when certain checks (type, range, etc.) are true.

Guard Clauses in Action

defmodule Validator do
  def validate_age(age) when is_integer(age) and age >= 0 and age < 18 do
    {:ok, "Minor"}
  end

  def validate_age(age) when is_integer(age) and age >= 18 do
    {:ok, "Adult"}
  end

  def validate_age(_age) do
    {:error, "Invalid age"}
  end
end

IO.inspect(Validator.validate_age(15))
IO.inspect(Validator.validate_age(25))
IO.inspect(Validator.validate_age(-5))

Explanation:

  • The module defines three clauses of the same function, validate_age/1. Elixir tries them from top to bottom and picks the first one whose pattern matches and whose guard evaluates to true.
  • First clause: applies only when age is an integer and between 0 and 17. It returns {:ok, "Minor"}.
  • Second clause: applies only when age is an integer and at least 18. It returns {:ok, "Adult"}.
  • Third clause: no guard, catches everything else — negative numbers, non-integers, etc. The parameter is named _age to signal it is intentionally ignored. It returns {:error, "Invalid age"}.
  • The three IO.inspect calls show:
    • 15 -> {:ok, "Minor"}
    • 25 -> {:ok, "Adult"}
    • -5 -> {:error, "Invalid age"} (it fails the first guard due to age >= 0 and fails the second guard due to age >= 18)
  • Why guards? They let you keep your routing logic clean and declarative. You match the input shape in the head, then refine with boolean checks like is_integer(age), comparisons, and logical and. Note: Guards accept a limited, “guard-safe” set of operations — perfect for validation like this.

What’s Allowed in Guards

  • Type checks: is_atom/1, is_binary/1, is_bitstring/1, is_boolean/1, is_float/1, is_function/1,2, is_integer/1, is_list/1, is_map/1, is_nil/1, is_number/1, is_pid/1, is_port/1, is_reference/1, is_tuple/1
  • Comparisons: ==, !=, ===, !==, <, <=, >, >=, and the in operator (with lists/ranges)
  • Arithmetic and numeric: +, -, *, div/2, rem/2, abs/1
  • Boolean operators: and, or, not (see operator nuances below)
  • Size/access helpers: byte_size/1, bit_size/1, tuple_size/1, map_size/1, elem/2, map_size/1

Not allowed:

  • User-defined functions or most library calls (e.g., Enum.*, Map.*). For example, Map.has_key?/2, Enum.count/1, length/1, hd/1, tl/1, and the / operator (floating-point division) are not allowed in guards.
  • Operators &&, ||, ! are not allowed in guards; use and, or, not instead.

Important: Only use functions and operators that are explicitly documented as guard-safe in the Elixir documentation. Using non-guard-safe functions in guards will cause compilation errors. Always avoid non-guard-safe functions in guard expressions.

Operator Nuances in Guards

  • Use and, or, not in guards. The operators &&, ||, ! are not allowed in guard expressions.
  • Precedence: and/or have lower precedence than comparisons and arithmetic. Use parentheses in complex guards for readability.

Example:

def ok_number?(x) when is_integer(x) and (x < -10 or x > 10), do: true
def ok_number?(_), do: false

Overlapping Guards and Ordering

Elixir evaluates clauses from top to bottom. If the pattern matches but the guard fails, it proceeds to the next clause.

defmodule Ranker do
  # Overlapping: ages 18..21 match both clauses. The first matching guard wins.
  def band(age) when is_integer(age) and age >= 18 and age <= 21, do: :young_adult
  def band(age) when is_integer(age) and age >= 18, do: :adult
  def band(_), do: :unknown
end

IO.inspect(Ranker.band(19))  # :young_adult (first clause wins)
IO.inspect(Ranker.band(30))  # :adult (second clause)
IO.inspect(Ranker.band("19"))# :unknown (pattern matches, guards fail; falls through)

When Something Isn’t Guard-safe

  • Checking list emptiness:

    • Instead of Enum.empty?(list) (not allowed), pattern-match the shape:
      def first([h | _t]), do: {:ok, h}
      def first([]), do: {:error, :empty}
      def first(_), do: {:error, :not_a_list}
  • Checking a map has a key:

    • Instead of Map.has_key?(m, :age) (not allowed), match the map shape:
      def handle(%{age: age}) when is_integer(age), do: {:ok, age}
      def handle(%{}), do: {:error, :missing_age}
      def handle(_), do: {:error, :not_a_map}

Reusable Guards

Compose domain checks once and reuse them across clauses.

defmodule Checks do
  import Kernel, except: []  # just to emphasize we use Kernel's guard-safe ops

  defguard is_adult(age) when is_integer(age) and age >= 18
  defguardp is_minor(age) when is_integer(age) and age >= 0 and age < 18
  # defguardp is private to this module
end

defmodule People do
  import Checks, only: [is_adult: 1]  # import public guard

  def label(age) when is_adult(age), do: {:ok, "Adult"}
  def label(age) when is_integer(age) and age >= 0, do: {:ok, "Minor"}
  def label(_), do: {:error, :invalid}
end

IO.inspect(People.label(21))  # {:ok, "Adult"}
IO.inspect(People.label(10))  # {:ok, "Minor"}
IO.inspect(People.label("x")) # {:error, :invalid}

You can also use guard macros in other guard-capable constructs (see below).

Guards Beyond Function Heads

Guards work in case, receive, and with expressions.

  • In case:

    import Checks, only: [is_adult: 1]
    
    case {:user, %{age: 20}} do
      {:user, %{age: a}} when is_adult(a) -> IO.puts("Adult user")
      {:user, %{age: a}} when is_integer(a) -> IO.puts("Minor user")
      _ -> IO.puts("Unknown")
    end
  • In receive:

    pid = self()
    send(pid, {:age, 17})
    send(pid, {:age, 22})
    
    receive do
      {:age, a} when is_integer(a) and a >= 18 -> IO.puts("Got adult age: #{a}")
      {:age, a} when is_integer(a) -> IO.puts("Got minor age: #{a}")
    after
      100 -> IO.puts("No message")
    end
  • In with (guards on patterns in generators/clauses):

    import Checks, only: [is_adult: 1]
    
    with {:ok, age} <- {:ok, 18},
         a when is_adult(a) <- age do
      IO.puts("With: adult age #{a}")
    else
      _ -> IO.puts("With: not adult")
    end

Differences: How They Handle Guard Failure

While the syntax for guards is the same across these constructs, how they behave when a guard fails differs:

  1. case: If a pattern matches but the guard fails, Elixir tries the next clause. If no clauses match, a CaseClauseError is raised.
  2. receive: If a guard fails, the message is not consumed. It remains in the process mailbox, and Elixir looks for the next message that might match.
  3. with: If a guard on a pattern match fails (e.g., a when a >= 18 <- age), the execution stops immediately. The result of the entire with block becomes the value that failed the match (or it proceeds to the else block if one is defined).

Summary and Next Steps

Today you learned how to:

  • Refine matches: Use when to attach type and range checks to function heads and control flow structures.
  • Stay "Guard-Safe": Only use allowed operations like is_integer/1 and and/or/not to avoid compilation errors.
  • Manage logic: Handle overlapping conditions using top-to-bottom ordering and defguard for reusability.
  • Differentiate context: Understand how failures vary between case (errors), receive (skips), and with (halts).

Ready to apply it? Head to the practice section and put guard clauses to work.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal