Writing Composable Guards in Elixir
Elixir ships with a couple of guards like is_map/1
, is_binary
, etc. which are
useful both when writing function clauses as well as in code. But if you find yourself
using them in combinations repeatedly for data-type or other checks, a better
option would be to define custom guards.
Give me a Guard
Let’s start with a very basic example to go over the syntax. Suppose you often need
to validate an age
argument in functions and need to make sure it’s a positive
integer that’s less than 120. Your functions might end up looking like this:
def update_age(%User{} = user, age) when is_integer(age) and age > 0 and age <= 100 do
# Return user with new age
end
With a guard, you could do this instead:
defguard is_valid_age(age) when is_integer(age) and age > 0 and age <= 100
def update_age(%User{} = user, age) when is_valid_age(age) do
# Return user with new age
end
Other than defguard
, you can also use defguardp
. Similar to def
and defp
,
the former is used to define a public guard, which can be called from other modules
and the latter is to define a private guard for internal use in a module.
Are you the Struct I’m looking for?
Though elixir already has an is_map
struct, I also sometimes needed an is_struct
for certain use-cases. So turns out that while Elixir doesn’t have
guards to work with maps, Erlang certainly does. We could just use them:
import :erlang, only: [is_map_key: 2, map_get: 2]
defguard is_struct(term) when
is_map(term) and
is_map_key(:__struct__, term) and
is_atom(map_get(:__struct__, term))
defguard is_struct(struct, module) when
is_struct(struct) and
map_get(:__struct__, struct) == module
Using them is straight forward:
shey = %Person{name: "Shey", age: 25}
is_struct(shey)
#=> true
is_struct(shey, Person)
#=> true
is_struct(shey, Movie)
#=> false
One real example of using this in the wild is having different user types sharing
common functionality/implementations, like Authentication. Imagine a marketplace
with completely independent user-types: Supplier
, Reseller
and Admin
- but
they share the same authentication logic. You could simplify calls like this:
defmodule Accounts do
@types [Admin, Supplier, Reseller]
defguard is_account(account) when
is_struct(account) and
map_get(:__struct__, account) in @types
def authenticate(account, password) when is_account(account) do
# Common authentication logic
end
def delete(account) when is_account(account) do
# Common deletion logic
end
# ...
end
Joining Them Together
You can continue to write guards and compose them together for a variety of use cases:
defmodule Billing do
defguard is_subscription(sub) when is_struct(sub, Billing.Subscription)
defguard is_active(sub) when map_get(:status, sub) == :active
defguard is_unpaid(sub) when map_get(:status, sub) == :unpaid
defguard is_trialing(sub) when map_get(:status, sub) == :trialing
def subscribe(%User{billing: billing}) when is_subscription(billing) do
{:error, :already_subscribed}
end
def subscribe(%User{billing: nil}) do
# Create and attach a subscription to the user
end
def end_trial(%User{billing: billing}) when is_subscription(billing) and is_trialing(billing) do
# End user's trial
end
def end_trial(_user), do: {:error, :not_trialing}
def cancel(%User{billing: billing}) when is_subscription(billing) and is_unpaid(billing) do
# Cancel Subscription
end
end
Of course the code above could be improved in other ways too, but this was simply meant as a demonstration of guards in elixir. You’ve seen how it can help reduce code repetition, and compared to Macros they can also be used in function clauses. To see more examples, check out the official documentation on HexDocs.