Validate and Sanitize Input in Elixir with GuardedStruct
A practical guide to cleaning and checking input in Elixir: one derives string per field, nested maps, conditional shapes, your own rules and Ash resources.
To validate and sanitize input in Elixir, declare the rules once next to the fields with GuardedStruct. builder/1 then trims, cleans and checks every value, and returns a struct or a list of errors. This guide builds a signup form step by step, with code we ran on guarded_struct 0.1.6.
Our GuardedStruct and Mishka CMS stay free. A founding seat pays for the time we spend on them and on the extensions, and it gives you lifetime access, priority support and every paid extension we release.
You need Elixir 1.20 and about fifteen minutes. Every output below is the real answer of the library, copied from an iex run.
By the end you have a signup struct that cleans messy input, rejects bad input with readable errors, builds nested structs, accepts one field in two shapes, runs a rule of your own and guards an Ash resource.
Mishka CMS releases. Once a month.
New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.
The short answer
Put derives: "sanitize(...) validate(...)" on each field of a guardedstruct block. The library cleans the value first and checks it second, so a padded or upper-case email passes and a wrong one fails with a clear error.

What builder/1 does: sanitize groups first, validate groups second, then an ok struct or error maps.
Step 1: install GuardedStruct
Add the package to mix.exs and run mix deps.get. It needs Elixir 1.20 or newer.
def deps do
[
{:guarded_struct, "~> 0.1.6"}
]
endSpark, Splode, telemetry and html_sanitize_ex come with it. Packages such as jason, ash, email_checker and ex_url are optional, and an op whose package is missing returns an error that names the package to add.
Step 2: declare a struct with derives
A guardedstruct block replaces defstruct, @enforce_keys, the typespec and a hand-written constructor. Each field takes a derives string: sanitizers first, validators second.
defmodule Signup do
use GuardedStruct
guardedstruct do
field :name, :string, enforce: true,
derives: "sanitize(trim, capitalize) validate(string, not_empty, max_len=80)"
field :email, :string, enforce: true,
derives: "sanitize(trim, downcase) validate(email_r)"
field :age, :integer,
derives: "validate(integer, min_len=18, max_len=120)"
field :role, :string, default: "user",
derives: "validate(enum=String[admin::user::guest])"
end
endemail_r checks an email with a regular expression and needs no extra package. The email op does a DNS lookup and needs email_checker. Note that min_len and max_len also bound the size of a number.
Step 3: build it and read the result
Call Signup.builder/1 with a map. Messy input comes back clean, and the default fills the field that was left out.
Signup.builder(%{name: " alice ", email: " [email protected] ", age: 30})
# {:ok, %Signup{name: "Alice", email: "[email protected]", age: 30, role: "user"}}Bad input never becomes a struct. You get {:error, errors}, one map per problem, with the field, the op that failed and a message.
Signup.builder(%{name: "x", email: "bad", age: 5})
# {:error,
# [
# %{field: :email, action: :email_r, message: "Incorrect email in the email field."},
# %{field: :age, action: :min_len, message: "The minimum number the age field is 18 and ..."}
# ]}
Signup.builder(%{email: "[email protected]"})
# {:error, [%{field: :name, action: :required_fields, message: "Please submit required fields."}]}
The answers of a real iex run on guarded_struct 0.1.6.
Here is the same run as a short animation.

Six frames: raw input, trim and capitalize, downcase, validate, the struct, and bad input.
To check one value without a struct, call GuardedStruct.Validate.run("validate(email_r)", "[email protected]"). It returns {:ok, "[email protected]"}, or {:error, [...]} with the field :__value__. The validate without a struct guide has more.
Step 4: sanitize HTML and lists
Sanitizers clean a value before any rule sees it. Use strip_tags for plain text and basic_html when a little markup is allowed.
defmodule Comment do
use GuardedStruct
guardedstruct do
field :body, :string,
derives: "sanitize(trim, strip_tags) validate(string, not_empty, max_len=500)"
field :bio, :string,
derives: "sanitize(basic_html) validate(string)"
end
end
Comment.builder(%{
body: " <script>alert(1)</script>Hello <b>there</b> ",
bio: "<p onclick=\"x()\">Hi <script>1</script><b>x</b></p>"
})
# {:ok, %Comment{body: "alert(1)Hello there", bio: "<p>Hi 1<b>x</b></p>"}}A sanitizer that raises does not let the raw value through. The field fails with a :sanitize error instead, and the library logs the exception without the value. A list field can run ops on every element with each=[...]:
field :tags, {:array, :string},
derives: "sanitize(each=[trim, downcase], reject_empty, uniq) validate(each=[string])"The input [" Elixir", "elixir ", "", "Ash"] became ["elixir", "ash"] in our run. The full list is in sanitize ops and validate ops.
Step 5: nest maps with sub_field
Use sub_field for a map inside the map. It becomes a real submodule with its own builder/1, and errors keep the name of the field they sit in.

Pick the block by the shape of the input.
defmodule Account do
use GuardedStruct
guardedstruct do
field :name, :string, enforce: true
sub_field :owner, struct(), enforce: true do
field :email, :string, enforce: true,
derives: "sanitize(trim, downcase) validate(email_r)"
field :role, :string, default: "owner"
end
end
end
Account.builder(%{name: "Acme", owner: %{email: " [email protected] "}})
# {:ok, %Account{name: "Acme", owner: %Account.Owner{email: "[email protected]", role: "owner"}}}The nested structs guide covers lists of sub structs and deeper levels.
Step 6: accept two shapes with conditional_field
When one field may be a string or a map, declare a conditional_field with one child per shape. The children are tried until one accepts the input.
defmodule Account do
use GuardedStruct
guardedstruct do
field :name, :string, enforce: true
conditional_field :plan, any() do
field :plan, :string, hint: "preset",
derives: "validate(enum=String[free::pro::enterprise])"
sub_field :plan, struct() do
field :tier, :string, enforce: true
field :seats, :integer, derives: "validate(integer, min_len=1)"
end
end
end
end
Account.builder(%{name: "Acme", plan: "pro"})
# {:ok, %Account{name: "Acme", plan: "pro"}}
Account.builder(%{name: "Acme", plan: %{tier: "custom", seats: 50}})
# {:ok, %Account{name: "Acme", plan: %Account.Plan1{tier: "custom", seats: 50}}}When no child accepts the input, the error is one :conditionals entry for the field, and it lists what each child said. The hint you set (here "preset") tells you which child spoke. See conditional fields for lists and nesting.
Step 7: add a rule of your own
A rule that is not built in goes into a module that uses GuardedStruct.Derive.Extension. Then the derives string can name it like any other op.
defmodule Blog.Derives do
use GuardedStruct.Derive.Extension
derives do
validator :slug_ok, fn input ->
is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
end
sanitizer :slugify, fn input when is_binary(input) ->
input
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
end
end
defmodule Post do
use GuardedStruct, derive_extensions: [Blog.Derives]
guardedstruct do
field :slug, :string, derives: "sanitize(slugify) validate(slug_ok)"
end
end
Post.builder(%{slug: " Hello, World! "})
# {:ok, %Post{slug: "hello-world"}}Step 8: guard an Ash resource
Add GuardedStruct.AshResource as an extension and set auto_wire true. The same rules then run inside your create and update actions, with no changes block to write.
defmodule MyApp.User do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: Ash.DataLayer.Ets,
extensions: [GuardedStruct.AshResource]
guardedstruct do
auto_wire true
field :email, :string,
derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
field :nickname, :string,
derives: "sanitize(trim) validate(string, max_len=20)"
end
attributes do
uuid_primary_key :id
attribute :email, :string, allow_nil?: false, public?: true
attribute :nickname, :string, public?: true
end
actions do
defaults [:read, :destroy]
create :create, accept: [:email, :nickname]
end
end
MyApp.User
|> Ash.Changeset.for_create(:create, %{email: " [email protected] "})
|> Ash.create()
# {:ok, %MyApp.User{email: "[email protected]", ...}}A bad value returns an Ash error, not a GuardedStruct one. With email: "nope" the action returned Ash.Error.Invalid that holds an Ash.Error.Changes.InvalidAttribute on :email with the message "Incorrect email in the email field." A form can show it at the field. Ash itself needs config :ash, default_string_length_count: :codepoints in a new project. The use with Ash guide covers updates and bulk actions.
Four mistakes to avoid
- Do not expect
validate(...)written first to run first. Every sanitize group runs before every validate group. - Do not trust
strip_tagsas output escaping. It removes tags, and the text between them stays. - Do not name a custom op like a built-in one. The built-in wins, and the compiler warns you.
- Do not write a derives string by feel. One the parser cannot read is a compile-time error, so run
mix compileearly.
Next steps
Start with the GuardedStruct page and keep the GuardedStruct docs open: derive syntax and handle errors are the two to read next.
We use GuardedStruct inside our own products. Read how it checks options in Unicode slugs in Elixir, or build an Ash admin with tables and forms in Mishka Gervaz.
GuardedStruct is free, and a small team builds it. If you want to help us work on it full time, a founding seat gives you lifetime access, priority support and every paid extension we release.
You can also sponsor our open source work, or hire our team for an Elixir project.
Mishka CMS releases. Once a month.
New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.
Questions people ask about GuardedStruct
Thank you
GuardedStruct stands on Elixir, Spark, Splode and Ash Framework. Thank you to everyone who builds them, and to you for reading this far.
Shahryar Tavakkoli writes about Elixir and Phoenix LiveView: Mishka Chelekom releases, GuardedStruct validation, static sites, service workers and SEO.