`defguard` creates reusable guard expressions

almirsarajcic

almirsarajcic

2 hours ago

0 comments

Repeating the same guard logic across multiple functions? Define it once with defguard and use it everywhere - in function heads, case, cond, and with clauses.

defmodule Guards do
  @moduledoc "Custom guards for domain-specific validation"

  defguard is_positive_integer(value)
           when is_integer(value) and value > 0

  defguard is_valid_age(age)
           when is_integer(age) and age >= 0 and age <= 150

  defguard is_valid_email_length(email)
           when is_binary(email) and byte_size(email) >= 5 and byte_size(email) <= 254
end

Import and use them exactly like built-in guards:

defmodule Accounts do
  import Guards, only: [is_positive_integer: 1, is_valid_age: 1]

  def get_user(id) when is_positive_integer(id) do
    Repo.get(User, id)
  end

  def get_user(_), do: {:error, :invalid_id}

  def create_user(%{age: age} = attrs) when is_valid_age(age) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  def create_user(_), do: {:error, :invalid_age}
end

Why not just use regular functions?

Guards run at pattern-match time with special optimizations. Regular functions can’t be used in guard position:

# This won't compile - regular functions not allowed in guards
def is_admin?(user), do: user.role == :admin

def admin_action(user) when is_admin?(user) do  # CompileError!
  # ...
end

# This works - defguard creates a macro that expands in guard context
defguard is_admin(user) when user.role == :admin

def admin_action(user) when is_admin(user) do  # Works!
  # ...
end

Works everywhere guards work

Custom guards work in all guard contexts:

import Guards, only: [is_positive_integer: 1]

# Function heads
def process(id) when is_positive_integer(id), do: {:ok, id}

# Case expressions
case value do
  n when is_positive_integer(n) -> {:positive, n}
  n when is_integer(n) -> {:non_positive, n}
  _ -> :not_integer
end

# With expressions
with id when is_positive_integer(id) <- Map.get(params, "id") do
  get_resource(id)
end

# Anonymous functions
Enum.filter(list, fn x when is_positive_integer(x) -> true; _ -> false end)

Combining guards

Build complex guards from simpler ones:

defmodule Validations do
  defguard is_non_empty_string(value)
           when is_binary(value) and byte_size(value) > 0

  defguard is_uuid_length(value)
           when is_binary(value) and byte_size(value) == 36

  # Combine existing guards
  defguard is_valid_uuid(value)
           when is_non_empty_string(value) and is_uuid_length(value)
end

Private guards with defguardp

Use defguardp for module-internal guards:

defmodule OrderProcessor do
  # Private - only usable in this module
  defguardp is_processable_status(status)
            when status in [:pending, :confirmed, :paid]

  def process(%Order{status: status} = order) when is_processable_status(status) do
    # Process the order
  end

  def process(%Order{status: status}) do
    {:error, {:invalid_status, status}}
  end
end

What’s allowed in defguard

Guards must use only allowed expressions - no arbitrary function calls:

Allowed:

  • Type checks: is_integer/1, is_binary/1, is_map/1, etc.
  • Comparisons: ==, !=, <, >, <=, >=, ===, !==
  • Boolean: and, or, not
  • Arithmetic: +, -, *, /, abs/1, rem/2
  • Binary/tuple: byte_size/1, tuple_size/1, elem/2
  • Other guards: in, is_nil/1, length/1, map_size/1

Not allowed:

  • Custom functions
  • Side effects
  • Exception-raising code
# Won't work - String.contains? isn't a guard-safe function
defguard has_at_sign(email) when String.contains?(email, "@")  # CompileError!

# Works - use guard-allowed operations instead
defguard has_minimum_length(str, min) when is_binary(str) and byte_size(str) >= min

defguard has been available since Elixir 1.6. If you’re copy-pasting the same guard expressions across your codebase, extract them into named guards for cleaner, more maintainable code.

Links:

Comments (0)

Sign in with GitHub to join the discussion