Learning Elixir: Control Flow with Guards

Guards provide a powerful way to extend pattern matching in Elixir, allowing you to validate data beyond simple structure and add type checking, value constraints, and more complex validation. This article explores how to effectively use guards to create robust, expressive, and maintainable code. Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary. Table of Contents Introduction Understanding Guards Guard Expressions Custom Guards Guard Limitations Common Use Cases Best Practices Conclusion Further Reading Next Steps Introduction In our previous articles, we explored various control flow structures in Elixir: if and unless for simple conditional logic case for pattern matching against values cond for evaluating multiple conditions with for chaining operations that depend on previous successes Now, we'll dive into guards, which work alongside pattern matching to provide more precise control over function clause selection and data validation. Guards serve as additional conditions that must be satisfied for pattern matching to succeed. They allow you to express requirements beyond structure, such as value ranges, data types, or relationships between variables. Understanding Guards Basic Guard Syntax Guards are introduced using the when keyword after a pattern match: def function_name(parameter) when guard_condition do # Function body end Let's look at a simple example: iex> defmodule NumberUtil do def absolute(number) when number >= 0 do number end def absolute(number) when number NumberUtil.absolute(5) 5 iex> NumberUtil.absolute(-5) 5 In this example: The first function clause matches when number is non-negative The second function clause matches when number is negative Guards provide a clean way to handle different cases without resorting to conditional expressions within the function body. Multiple Guards You can combine multiple guard conditions using and, or, and parentheses: iex> defmodule AgeValidator do def valid_age?(age) when is_integer(age) and age >= 0 and age AgeValidator.valid_age?(25) true iex> AgeValidator.valid_age?(-5) false iex> AgeValidator.valid_age?(200) false iex> AgeValidator.valid_age?("twenty") false Here the function only returns true when all three conditions are satisfied: the input is an integer, greater than or equal to 0, and less than 130. Guards vs. Pattern Matching Pattern matching checks the structure and literal values: iex> defmodule User do def greet({:user, name}) do "Hello, #{name}!" end def greet({:admin, name}) do "Welcome, Administrator #{name}!" end end iex> User.greet({:user, "John"}) "Hello, John!" iex> User.greet({:admin, "Sarah"}) "Welcome, Administrator Sarah!" Guards extend this by adding constraints on values: iex> defmodule UserValidator do def greet({:user, name}) when is_binary(name) and byte_size(name) > 0 do "Hello, #{name}!" end def greet({:admin, name}) when is_binary(name) and byte_size(name) > 0 do "Welcome, Administrator #{name}!" end def greet(_) do "Invalid user data" end end iex> UserValidator.greet({:user, "John"}) "Hello, John!" iex> UserValidator.greet({:user, ""}) "Invalid user data" iex> UserValidator.greet({:admin, 123}) "Invalid user data" Now both functions only match when the name is a non-empty string. Guard Expressions Let's explore the different types of expressions that can be used within guards and how they can be combined to create powerful validation logic. Built-in Guard Functions Elixir restricts the functions that can be used in guard clauses to ensure they're side-effect free, deterministic, and won't raise exceptions. Here are some commonly used built-in guard functions: Type Check Functions # Type checks is_atom/1 is_binary/1 is_bitstring/1 is_boolean/1 is_float/1 is_function/1 is_function/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 Comparison and Mathematical Functions # Comparison >/2, =/2, = 18 do {:ok, "#{name} is #{age} years old"} end # Fallback clause for invalid inputs def process_triple(_), do: {:error, "Invalid triple"} def process_even(_), do: {:error, "Not a positive even integer"} def process_user(_), do: {:error, "Invalid user data"} end iex> Validator.process_triple([1, 2, 3]) {:ok, 6} iex> Validator.process_triple([1, 2]) {:error, "Invalid triple"} iex> Validator.process_triple([1, 2, "3"]) {:error, "Invalid triple"} iex> Validator.process_even(4) {:ok, 8} iex> Validator.process_even(3) {:error, "Not a positive even integer"} iex> Validator.process_even(-2) {:error, "Not

Mar 19, 2025 - 15:36
 0
Learning Elixir: Control Flow with Guards

Guards provide a powerful way to extend pattern matching in Elixir, allowing you to validate data beyond simple structure and add type checking, value constraints, and more complex validation. This article explores how to effectively use guards to create robust, expressive, and maintainable code.

Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary.

Table of Contents

  • Introduction
  • Understanding Guards
  • Guard Expressions
  • Custom Guards
  • Guard Limitations
  • Common Use Cases
  • Best Practices
  • Conclusion
  • Further Reading
  • Next Steps

Introduction

In our previous articles, we explored various control flow structures in Elixir:

  • if and unless for simple conditional logic
  • case for pattern matching against values
  • cond for evaluating multiple conditions
  • with for chaining operations that depend on previous successes

Now, we'll dive into guards, which work alongside pattern matching to provide more precise control over function clause selection and data validation.

Guards serve as additional conditions that must be satisfied for pattern matching to succeed. They allow you to express requirements beyond structure, such as value ranges, data types, or relationships between variables.

Understanding Guards

Basic Guard Syntax

Guards are introduced using the when keyword after a pattern match:

def function_name(parameter) when guard_condition do
  # Function body
end

Let's look at a simple example:

iex> defmodule NumberUtil do
      def absolute(number) when number >= 0 do
        number
      end

      def absolute(number) when number < 0 do
        -number
      end
    end

iex> NumberUtil.absolute(5)
5

iex> NumberUtil.absolute(-5)
5

In this example:

  • The first function clause matches when number is non-negative
  • The second function clause matches when number is negative

Guards provide a clean way to handle different cases without resorting to conditional expressions within the function body.

Multiple Guards

You can combine multiple guard conditions using and, or, and parentheses:

iex> defmodule AgeValidator do
      def valid_age?(age) when is_integer(age) and age >= 0 and age < 130 do
        true
      end
      def valid_age?(_), do: false
    end

iex> AgeValidator.valid_age?(25)
true

iex> AgeValidator.valid_age?(-5)
false

iex> AgeValidator.valid_age?(200)
false

iex> AgeValidator.valid_age?("twenty")
false

Here the function only returns true when all three conditions are satisfied: the input is an integer, greater than or equal to 0, and less than 130.

Guards vs. Pattern Matching

Pattern matching checks the structure and literal values:

iex> defmodule User do
      def greet({:user, name}) do
        "Hello, #{name}!"
      end

      def greet({:admin, name}) do
        "Welcome, Administrator #{name}!"
      end
    end

iex> User.greet({:user, "John"})
"Hello, John!"

iex> User.greet({:admin, "Sarah"})
"Welcome, Administrator Sarah!"

Guards extend this by adding constraints on values:

iex> defmodule UserValidator do
      def greet({:user, name}) when is_binary(name) and byte_size(name) > 0 do
        "Hello, #{name}!"
      end

      def greet({:admin, name}) when is_binary(name) and byte_size(name) > 0 do
        "Welcome, Administrator #{name}!"
      end

      def greet(_) do
        "Invalid user data"
      end
    end

iex> UserValidator.greet({:user, "John"})
"Hello, John!"

iex> UserValidator.greet({:user, ""})
"Invalid user data"

iex> UserValidator.greet({:admin, 123})
"Invalid user data"

Now both functions only match when the name is a non-empty string.

Guard Expressions

Let's explore the different types of expressions that can be used within guards and how they can be combined to create powerful validation logic.

Built-in Guard Functions

Elixir restricts the functions that can be used in guard clauses to ensure they're side-effect free, deterministic, and won't raise exceptions. Here are some commonly used built-in guard functions:

Type Check Functions

# Type checks
is_atom/1
is_binary/1
is_bitstring/1
is_boolean/1
is_float/1
is_function/1
is_function/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

Comparison and Mathematical Functions

# Comparison
>/2, 2, >=/2, <=/2
==/2, ===/2, !=/2, !==/2

# Math
+/1, -/1, +/2, -/2, */2, //2
abs/1, div/2, rem/2

Bitwise Operations

band/2    # Bitwise AND
bor/2     # Bitwise OR
bxor/2    # Bitwise XOR
bnot/1    # Bitwise NOT
bsl/2     # Bitwise shift left
bsr/2     # Bitwise shift right

Binary and Bitstring Functions

byte_size/1
bit_size/1

List Operations

hd/1      # Head of list
tl/1      # Tail of list
length/1

Map Functions

map_size/1

Tuple Functions

tuple_size/1
elem/2

These built-in functions form the foundation for creating guard expressions that can validate a wide range of conditions while maintaining the safety guarantees that guards require.

Complex Guard Examples

Let's see how to use these functions in more complex guards:

iex> defmodule Validator do
      # Valid only for lists with exactly 3 integers
      def process_triple(list) when is_list(list) and length(list) == 3 and
                                   is_integer(hd(list)) and
                                   is_integer(hd(tl(list))) and
                                   is_integer(hd(tl(tl(list)))) do
        [a, b, c] = list
        {:ok, a + b + c}
      end

      # Valid only for positive even integers
      def process_even(n) when is_integer(n) and n > 0 and rem(n, 2) == 0 do
        {:ok, n * 2}
      end

      # For maps, it's better to use pattern matching directly in the arguments
      # instead of checking in the guard
      def process_user(%{name: name, age: age}) when is_binary(name) and
                                                    is_integer(age) and
                                                    age >= 18 do
        {:ok, "#{name} is #{age} years old"}
      end

      # Fallback clause for invalid inputs
      def process_triple(_), do: {:error, "Invalid triple"}
      def process_even(_), do: {:error, "Not a positive even integer"}
      def process_user(_), do: {:error, "Invalid user data"}
    end

iex> Validator.process_triple([1, 2, 3])
{:ok, 6}

iex> Validator.process_triple([1, 2])
{:error, "Invalid triple"}

iex> Validator.process_triple([1, 2, "3"])
{:error, "Invalid triple"}

iex> Validator.process_even(4)
{:ok, 8}

iex> Validator.process_even(3)
{:error, "Not a positive even integer"}

iex> Validator.process_even(-2)
{:error, "Not a positive even integer"}

iex> Validator.process_user(%{name: "John", age: 25})
{:ok, "John is 25 years old"}

iex> Validator.process_user(%{name: "Jane", age: 17})
{:error, "Invalid user data"}

iex> Validator.process_user(%{name: 123, age: 30})
{:error, "Invalid user data"}

These examples demonstrate how guards can be used to validate input data with complex requirements, providing clear feedback when those requirements are not met.

Custom Guards

As your validation requirements become more complex, you'll likely find yourself repeating similar guard expressions across different functions. Elixir provides a solution to this problem through custom guards.

Understanding defguard and defguardp

Starting from Elixir 1.6, you can define your own reusable guard expressions using the defguard and defguardp macros:

  • defguard creates a public, named guard expression that can be used within your module and imported by other modules
  • defguardp creates a private guard expression that can only be used within the defining module

These macros allow you to:

  1. Give meaningful names to complex guard conditions
  2. Encapsulate and reuse guard logic
  3. Create a domain-specific vocabulary for validations
  4. Improve code readability and maintainability

Basic defguard Syntax

iex> defmodule Guards do
       # Define a public guard
       defguard is_valid_age(age) when is_integer(age) and age > 0 and age < 130

       # Define a private guard
       defguardp is_admin(user) when is_map(user) and user.role == :admin

       # Using a public guard
       def process_age(age) when is_valid_age(age) do
         {:ok, age}
       end

       # Using a private guard
       def authorize(user) when is_admin(user) do
         {:ok, :authorized}
       end

       # Fallback clauses
       def process_age(_), do: {:error, "Invalid age"}
       def authorize(_), do: {:error, :unauthorized}
     end

iex> Guards.process_age(25)
{:ok, 25}

iex> Guards.process_age(-5)
{:error, "Invalid age"}

iex> Guards.authorize(%{role: :admin})
{:ok, :authorized}

iex> Guards.authorize(%{role: :user})
{:error, :unauthorized}

When you define a guard with defguard or defguardp, Elixir creates a macro that expands to the guard expression you defined. This means it's not a function call (which would not be allowed in guards) but rather a direct substitution of the expression at compile time.

Composing Complex Guards

Custom guards can be combined to create more complex validation logic:

iex> defmodule NumberValidator do
       defguard is_positive(value) when is_number(value) and value > 0
       defguard is_negative(value) when is_number(value) and value < 0
       defguard is_non_negative(value) when is_number(value) and value >= 0
       defguard is_even(value) when is_integer(value) and rem(value, 2) == 0
       defguard is_odd(value) when is_integer(value) and rem(value, 2) != 0
       defguard is_multiple_of(value, divisor) when is_integer(value) and is_integer(divisor) and divisor != 0 and rem(value, divisor) == 0

       def classify(num) when is_positive(num) and is_even(num) do
         "positive even"
       end

       def classify(num) when is_positive(num) and is_odd(num) do
         "positive odd"
       end

       def classify(num) when is_negative(num) and is_even(num) do
         "negative even"
       end

       def classify(num) when is_negative(num) and is_odd(num) do
         "negative odd"
       end

       def classify(0), do: "zero"
       def classify(_), do: "not a number"

       def divisible_by_three?(num) when is_multiple_of(num, 3), do: true
       def divisible_by_three?(_), do: false
     end

iex> NumberValidator.classify(10)
"positive even"

iex> NumberValidator.classify(7)
"positive odd"

iex> NumberValidator.classify(-4)
"negative even"

iex> NumberValidator.classify(-3)
"negative odd"

iex> NumberValidator.classify(0)
"zero"

iex> NumberValidator.classify("hello")
"not a number"

iex> NumberValidator.divisible_by_three?(9)
true

iex> NumberValidator.divisible_by_three?(10)
false

In this example, we've defined several meaningful guard expressions that:

  • Encapsulate common number validation logic
  • Create a domain-specific vocabulary for our application
  • Improve code readability by naming complex conditions
  • Allow for easy composition of complex validation rules

Notice how the same constraints apply to custom guards as to regular guard expressions:

  • They can only use allowed guard functions and operators
  • They must be side-effect free and deterministic
  • They should be kept reasonably simple to maintain readability

Exporting Guards for Reuse

Custom guards can be imported and used across modules:

iex> defmodule MyAppGuards do
       # Simple type and range validations that are guard-safe
       defguard is_valid_age(age) when is_integer(age) and age >= 0 and age <= 120

       # String validations that are guard-safe
       defguard is_non_empty_string(str) when is_binary(str) and byte_size(str) > 0
       defguard is_limited_string(str, max) when is_binary(str) and byte_size(str) <= max

       # Combined string validation
       defguard is_valid_name(name) when is_non_empty_string(name) and is_limited_string(name, 100)
     end

iex> defmodule UserValidator do
       import MyAppGuards

       # Using pattern matching for map structure and imported guards for validation
       def validate_user(%{name: name, age: age, email: email})
           when is_valid_name(name) and
                is_valid_age(age) and
                is_non_empty_string(email) do

         # Email validation can't be done in guard (String.contains? is not guard-safe)
         # So we do the email validation in the function body
         if String.contains?(email, "@") do
           {:ok, %{name: name, age: age, email: email}}
         else
           {:error, "Invalid email format"}
         end
       end

       def validate_user(_), do: {:error, "Invalid user data"}
     end

iex> UserValidator.validate_user(%{name: "John", age: 25, email: "john@example.com"})
{:ok, %{name: "John", age: 25, email: "john@example.com"}}

iex> UserValidator.validate_user(%{name: "", age: 25, email: "john@example.com"})
{:error, "Invalid user data"}

iex> UserValidator.validate_user(%{name: "John", age: -5, email: "john@example.com"})
{:error, "Invalid user data"}

iex> UserValidator.validate_user(%{name: "John", age: 25, email: "johnexample.com"})
{:error, "Invalid email format"}

This approach centralizes your validation logic, making it easier to maintain and ensuring consistency across your application.

Guard Limitations

While guards are extremely useful, they do have some limitations that you should be aware of when designing your code. Understanding these limitations will help you make better decisions about when to use guards and when to use alternative approaches.

Restrictions on Functions

Not all Elixir functions can be used in guards. Guards can only use:

  1. Built-in guard functions (like the ones listed earlier)
  2. Custom guards defined with defguard
  3. Operators like +, -, *, etc.

You cannot use:

  1. Regular function calls that are not explicitly allowed in guards
  2. Functions with side effects
  3. Functions that might raise exceptions

For example, this won't work:

# This will fail to compile
defmodule StringProcessor do
  # This will fail to compile with error about String.starts_with? not being allowed in guards
  def process_string(str) when String.starts_with?(str, "prefix") do
    "Processing: " <> str
  end

  # Fallback clause
  def process_string(str), do: "Cannot process: " <> str
end

Because String.starts_with?/2 is not allowed in guards.

Working Around Limitations

Given these limitations, let's look at some strategies for dealing with them effectively.

Using Pattern Matching

Pattern matching often provides a cleaner alternative to guards for structural checks:

iex> defmodule ResponseHandler do
       # Using a guard to check tuple element
       def process_with_guard(tuple) when is_tuple(tuple) and elem(tuple, 0) == :ok do
         {:ok, value} = tuple
         "Success with: #{inspect(value)}"
       end
       def process_with_guard(_), do: "Not an ok tuple"

       # Using pattern matching directly (more idiomatic)
       def process_with_pattern({:ok, value}) do
         "Success with: #{inspect(value)}"
       end
       def process_with_pattern(_), do: "Not an ok tuple"
     end

iex> ResponseHandler.process_with_guard({:ok, 42})
"Success with: 42"
iex> ResponseHandler.process_with_guard({:error, "oops"})
"Not an ok tuple"

iex> ResponseHandler.process_with_pattern({:ok, 42})
"Success with: 42"
iex> ResponseHandler.process_with_pattern({:error, "oops"})
"Not an ok tuple"

This shows how pattern matching often provides a cleaner alternative to guards for structural checks.

Combining Pattern Matching and Guards

iex> defmodule Parser do
       # Process a tuple with ok atom and a binary value
       def parse({:ok, value}) when is_binary(value) do
         "Got string: #{value}"
       end

       # Process a tuple with ok atom and a numeric value
       def parse({:ok, value}) when is_number(value) do
         "Got number: #{value}"
       end

       # Fallback clause
       def parse(_), do: "Invalid input"
     end

iex> Parser.parse({:ok, "hello"})
"Got string: hello"

iex> Parser.parse({:ok, 42})
"Got number: 42"

iex> Parser.parse({:ok, :atom})
"Invalid input"

iex> Parser.parse({:error, "oops"})
"Invalid input"

Using Function Clauses Instead of Complex Guards

When guards become too complex, consider splitting the logic into multiple function clauses:

# Instead of:
def complex_process(data) when is_list(data) and length(data) > 0 and
                              hd(data) > 0 and is_map(hd(tl(data))) do
  # ...
end

# Use:
def complex_process([first | rest]) when is_number(first) and first > 0 do
  process_with_positive_first(first, rest)
end
def complex_process(_), do: {:error, "Invalid data"}

defp process_with_positive_first(first, [second | rest]) when is_map(second) do
  # Process valid data
  {:ok, first, second, rest}
end
defp process_with_positive_first(_, _), do: {:error, "Invalid data structure"}

This approach breaks down complex validation into smaller, more manageable steps, making the code easier to read and maintain.

Common Use Cases

Now that we understand how guards work and their limitations, let's explore some common use cases where they excel.

Input Validation

Guards excel at validating function inputs:

iex> defmodule Calculator do
       def divide(a, b) when is_number(a) and is_number(b) and b != 0 do
         {:ok, a / b}
       end
       def divide(_, 0), do: {:error, "Cannot divide by zero"}
       def divide(_, _), do: {:error, "Both arguments must be numbers"}

       def sqrt(x) when is_number(x) and x >= 0 do
         {:ok, :math.sqrt(x)}
       end
       def sqrt(x) when is_number(x), do: {:error, "Cannot calculate square root of negative number"}
       def sqrt(_), do: {:error, "Argument must be a number"}
     end

iex> Calculator.divide(10, 2)
{:ok, 5.0}

iex> Calculator.divide(10, 0)
{:error, "Cannot divide by zero"}

iex> Calculator.divide("10", 2)
{:error, "Both arguments must be numbers"}

iex> Calculator.sqrt(16)
{:ok, 4.0}

iex> Calculator.sqrt(-4)
{:error, "Cannot calculate square root of negative number"}

iex> Calculator.sqrt("16")
{:error, "Argument must be a number"}

This approach provides a clear and self-documenting way to handle different types of input errors.

Type-Specific Processing

Guards help handle different data types appropriately:

iex> defmodule Formatter do
       def format(value) when is_binary(value), do: {:string, value}
       def format(value) when is_integer(value), do: {:integer, Integer.to_string(value)}
       def format(value) when is_float(value), do: {:float, :io_lib.format("~.2f", [value]) |> to_string()}
       def format(value) when is_boolean(value), do: {:boolean, to_string(value)}
       def format(value) when is_map(value), do: {:map, inspect(value)}
       def format(value) when is_list(value), do: {:list, inspect(value)}
       def format(_), do: {:unknown, "unknown type"}
     end

iex> Formatter.format("hello")
{:string, "hello"}

iex> Formatter.format(42)
{:integer, "42"}

iex> Formatter.format(3.14159)
{:float, "3.14"}

iex> Formatter.format(true)
{:boolean, "true"}

iex> Formatter.format(%{key: "value"})
{:map, "%{key: \"value\"}"}

iex> Formatter.format([1, 2, 3])
{:list, "[1, 2, 3]"}

iex> Formatter.format(:atom)
{:unknown, "unknown type"}

This pattern allows you to provide type-specific processing without complicated conditional logic.

Pattern Matching with Guards in Case Expressions

Guards also work with case expressions:

iex> defmodule DataProcessor do
       def process(data) do
         case data do
           value when is_binary(value) ->
             "Processing string: #{value}"

           value when is_number(value) and value > 0 ->
             "Processing positive number: #{value}"

           value when is_number(value) ->
             "Processing non-positive number: #{value}"

           value when is_list(value) and length(value) > 0 ->
             "Processing non-empty list with #{length(value)} items"

           value when is_map(value) and map_size(value) > 0 ->
             "Processing non-empty map with #{map_size(value)} keys"

           _ ->
             "Unknown or empty data structure"
         end
       end
     end

iex> DataProcessor.process("hello")
"Processing string: hello"

iex> DataProcessor.process(42)
"Processing positive number: 42"

iex> DataProcessor.process(0)
"Processing non-positive number: 0"

iex> DataProcessor.process(-10)
"Processing non-positive number: -10"

iex> DataProcessor.process([1, 2, 3])
"Processing non-empty list with 3 items"

iex> DataProcessor.process(%{a: 1, b: 2})
"Processing non-empty map with 2 keys"

iex> DataProcessor.process([])
"Unknown or empty data structure"

iex> DataProcessor.process(%{})
"Unknown or empty data structure"

iex> DataProcessor.process(:atom)
"Unknown or empty data structure"

The case expression combined with guards provides a clean way to handle multiple data types with different validation requirements.

Function Dispatching

Guards are excellent for dispatching to different processing logic based on data type and content:

iex> defmodule Processor do
       def process(data) when is_binary(data) do
         process_string(data)
       end

       def process(data) when is_list(data) and length(data) > 0 do
         process_list(data)
       end

       def process(data) when is_map(data) and map_size(data) > 0 do
         process_map(data)
       end

       def process(_), do: {:error, "Unsupported data format"}

       defp process_string(data) do
         {:ok, "Processed string: #{String.upcase(data)}"}
       end

       defp process_list(data) do
         {:ok, "Processed list with #{length(data)} items"}
       end

       defp process_map(data) do
         {:ok, "Processed map with keys: #{inspect(Map.keys(data))}"}
       end
     end

iex> Processor.process("hello")
{:ok, "Processed string: HELLO"}

iex> Processor.process([1, 2, 3])
{:ok, "Processed list with 3 items"}

iex> Processor.process(%{name: "John", age: 30})
{:ok, "Processed map with keys: [:age, :name]"}

iex> Processor.process([])
{:error, "Unsupported data format"}

iex> Processor.process(:atom)
{:error, "Unsupported data format"}

This pattern creates a clear separation of concerns, with each function clause handling a specific type of data.

Best Practices

Now that we've explored the power and flexibility of guards, let's discuss some best practices to help you use them effectively in your Elixir code.

Keep Guards Simple and Focused

Complex guards can be hard to read and maintain. When guards become too complex, consider:

  1. Breaking them into smaller custom guards using defguard
  2. Using multiple function clauses instead of a single complex guard
  3. Moving complex validation logic into separate validation functions
# Instead of:
def process(x, y, z) when is_number(x) and x > 0 and is_number(y) and y > 0 and
                           is_number(z) and z > 0 and x + y > z and
                           y + z > x and x + z > y do
  # Triangle validation logic
end

# Use custom guards:
iex> defmodule Geometry do
       defguard is_positive(x) when is_number(x) and x > 0
       defguard is_valid_triangle_sides(a, b, c) when a + b > c and b + c > a and a + c > b

       def triangle_area(a, b, c) when is_positive(a) and
                                      is_positive(b) and
                                      is_positive(c) and
                                      is_valid_triangle_sides(a, b, c) do
         s = (a + b + c) / 2
         area = :math.sqrt(s * (s - a) * (s - b) * (s - c))
         {:ok, area}
       end

       def triangle_area(_, _, _), do: {:error, "Invalid triangle sides"}
     end

iex> Geometry.triangle_area(3, 4, 5)
{:ok, 6.0}

iex> Geometry.triangle_area(1, 1, 3)
{:error, "Invalid triangle sides"}

iex> Geometry.triangle_area(-1, 4, 5)
{:error, "Invalid triangle sides"}

Leverage Guard Composition

Build complex validation logic by combining simpler guard functions:

iex> defmodule ValidationUtils do
        # Basic type validations
        defguard is_string(value) when is_binary(value)
        defguard is_positive_number(value) when is_number(value) and value > 0

        # String validations
        defguard is_non_empty_string(value) when is_string(value) and byte_size(value) > 0
        defguard is_short_string(value) when is_string(value) and byte_size(value) <= 50

        # Combining validations
        defguard is_valid_name(value) when is_non_empty_string(value) and is_short_string(value)

        # Number validations
        defguard is_percentage(value) when is_number(value) and value >= 0 and value <= 100
        defguard is_valid_year(value) when is_integer(value) and value > 1900 and value < 2100

        # Example usage of the guards
        def validate_name(name) when is_valid_name(name) do
          {:ok, name}
        end
        def validate_name(_), do: {:error, "Invalid name"}

        def validate_year(year) when is_valid_year(year) do
          {:ok, year}
        end
        def validate_year(_), do: {:error, "Invalid year"}
      end

iex> ValidationUtils.validate_name("John Doe")
{:ok, "John Doe"}

iex> ValidationUtils.validate_name("")
{:error, "Invalid name"}

iex> ValidationUtils.validate_name(String.duplicate("A", 100))
{:error, "Invalid name"}

iex> ValidationUtils.validate_year(2023)
{:ok, 2023}

iex> ValidationUtils.validate_year(1800)
{:error, "Invalid year"}

This approach creates a vocabulary of validations that can be imported and used throughout your application.

Use Guards to Document Requirements

Guards provide not just validation but also documentation of your function's requirements:

iex> defmodule AccountManager do
      @doc """
      Transfers money from one account to another.

      ## Parameters

      - from_account: Source account map with :balance key
      - to_account: Destination account map with :balance key
      - amount: Positive number representing transfer amount

      ## Examples

          iex> from = %{balance: 100}
          iex> to = %{balance: 50}
          iex> AccountManager.transfer(from, to, 30)
          {:ok, %{balance: 70}, %{balance: 80}}

      """
      def transfer(%{balance: from_balance} = from_account,
                  %{balance: to_balance} = to_account,
                  amount)
          when is_number(from_balance) and is_number(to_balance) and
               is_number(amount) and amount > 0 and
               from_balance >= amount do

        from = Map.update!(from_account, :balance, &(&1 - amount))
        to = Map.update!(to_account, :balance, &(&1 + amount))

        {:ok, from, to}
      end

      def transfer(%{balance: from_balance} = from_account, _, amount)
          when is_number(from_balance) and
               is_number(amount) and
               from_balance < amount do
        {:error, :insufficient_funds}
      end

      def transfer(_, _, _), do: {:error, :invalid_input}
    end

iex> from = %{balance: 100}
iex> to = %{balance: 50}
iex> AccountManager.transfer(from, to, 30)
{:ok, %{balance: 70}, %{balance: 80}}

iex> AccountManager.transfer(from, to, 200)
{:error, :insufficient_funds}

iex> AccountManager.transfer("not an account", to, 30)
{:error, :invalid_input}

The guards in this example clearly communicate the requirements for a valid account transfer.

Use Pattern Matching with Guards

Combine the power of pattern matching and guards for cleaner code:

iex> defmodule API do
       # Success responses
       def handle_response({:ok, %{status: status, body: body}})
           when status >= 200 and status < 300 and is_map(body) do
         {:ok, body}
       end

       # Redirection responses
       def handle_response({:ok, %{status: status, headers: headers}})
           when status >= 300 and status < 400 and is_map(headers) do
         {:redirect, headers["location"]}
       end

       # Client error responses
       def handle_response({:ok, %{status: status, body: body}})
           when status >= 400 and status < 500 do
         {:client_error, status, body}
       end

       # Server error responses
       def handle_response({:ok, %{status: status}})
           when status >= 500 do
         {:server_error, status}
       end

       # Network or other errors
       def handle_response({:error, reason}), do: {:network_error, reason}

       # Catch-all for unexpected formats
       def handle_response(_), do: {:error, :invalid_response}
     end

iex> API.handle_response({:ok, %{status: 200, body: %{data: "some data"}}})
{:ok, %{data: "some data"}}

# Testing redirection response
iex> API.handle_response({:ok, %{status: 302, headers: %{"location" => "/new-url"}}})
{:redirect, "/new-url"}

# Testing client error response
iex> API.handle_response({:ok, %{status: 404, body: %{error: "Not found"}}})
{:client_error, 404, %{error: "Not found"}}

# Testing server error response
iex> API.handle_response({:ok, %{status: 500}})
{:server_error, 500}

# Testing network error response
iex> API.handle_response({:error, :timeout})
{:network_error, :timeout}

# Testing invalid response format
iex> API.handle_response(:not_an_api_response)
{:error, :invalid_response}

This example shows how pattern matching and guards can work together to create clear, expressive code for handling different response scenarios.

Conclusion

Guards are a powerful feature in Elixir that extend pattern matching capabilities, allowing for more precise control over function clause selection based on data types, value constraints, and relationships between variables.

Through this article, we've explored:

  • The fundamentals of guard clauses and how they complement pattern matching
  • Built-in guard functions and their usage in complex guard expressions
  • Creating and composing custom guards using defguard
  • Understanding and working around guard limitations
  • Best practices for writing clean, maintainable, and expressive guards

By mastering guards, you can write more robust Elixir code that clearly communicates its requirements and gracefully handles different scenarios. Guards help you express validation logic in a declarative way, making your code more self-documenting and easier to reason about.

Tip: Think of guards as a way to enforce contracts on your function's inputs. They both validate and document the expectations of your functions, making your code more reliable and self-documenting.

In our next article on "Advanced Flow Control," we'll build on these concepts to explore how different control structures can be combined to handle complex logic, create multi-stage processing pipelines, and implement sophisticated error handling strategies.

Further Reading

Next Steps

In the upcoming article, we'll explore Advanced Flow Control:

Advanced Flow Control

  • Combining different control structures for complex logic
  • Multi-stage processing pipelines
  • Handling complex state transitions
  • Error handling strategies
  • Best practices for complex control flow