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

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
andunless
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:
- Give meaningful names to complex guard conditions
- Encapsulate and reuse guard logic
- Create a domain-specific vocabulary for validations
- 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:
- Built-in guard functions (like the ones listed earlier)
- Custom guards defined with
defguard
- Operators like
+
,-
,*
, etc.
You cannot use:
- Regular function calls that are not explicitly allowed in guards
- Functions with side effects
- 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:
- Breaking them into smaller custom guards using
defguard
- Using multiple function clauses instead of a single complex guard
- 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
- Elixir Documentation - Guards
- Elixir Documentation - defguard/1
- Elixir School - Pattern Matching and Guards
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