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.

Validate and Sanitize Input in Elixir with GuardedStruct

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.

Time left until the offer ends
114
D
23
H
56
M
08
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

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.

Newsletter

Mishka CMS releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam
0.1.6latest release on Hex
74,000+downloads on Hex
1block declares fields, rules and errors
2groups: sanitize first, then validate

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.

1 · InstallAdd the depOne line in mix.exs
2 · DeclareWrite the structFields and derives strings
3 · BuildCall builder/1Ok struct or error maps
4 · Nestsub_field and conditional_fieldMaps and mixed shapes
5 · ExtendYour own rules and AshDerive.Extension, auto_wire
Diagram of the GuardedStruct builder: raw input goes through sanitize groups, then validate groups, and ends as an ok struct or a list of error maps

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.

elixir
def deps do
  [
    {:guarded_struct, "~> 0.1.6"}
  ]
end

Spark, 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.

elixir
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
end

email_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.

elixir
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.

elixir
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."}]}
An iex session where Signup.builder returns a clean struct for messy input and error maps for bad input

The answers of a real iex run on guarded_struct 0.1.6.

Here is the same run as a short animation.

Animation of a map with a messy name and email being trimmed, lower-cased and validated by GuardedStruct, ending as a Signup struct, then bad input returning two error maps

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.

elixir
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=[...]:

elixir
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.

Four GuardedStruct blocks: field for one value, sub_field for a nested map, conditional_field for several shapes and Derive.Extension for your own rules

Pick the block by the shape of the input.

elixir
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.

elixir
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.

elixir
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.

elixir
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_tags as 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 compile early.

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.

Time left until the offer ends
114
D
23
H
56
M
08
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

You can also sponsor our open source work, or hire our team for an Elixir project.

Newsletter

Mishka CMS releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam

Questions people ask about GuardedStruct

An Elixir library that builds structs with validation and sanitizing declared in one block. You write the fields and a derives string, and builder/1 returns an ok struct or a list of error maps.

Yes. Every sanitize group runs first, in the order written, and every validate group runs after it on the cleaned value, wherever the groups sit in the derives string.

It returns {:error, errors}. Each error is a map with field, action and message, and an error inside a sub_field comes back under the name of that field.

Yes. Write a module with use GuardedStruct.Derive.Extension, declare a validator or sanitizer in its derives block, and pass the module with derive_extensions: or register it in config.

Yes. Add GuardedStruct.AshResource as an extension and set auto_wire true in its guardedstruct block. Create and update actions then run the sanitize and validate rules, and a bad value returns an Ash invalid error at its field.

Version 0.1.6 requires Elixir 1.20 or newer. Optional packages such as ash, jason, email_checker and ex_url are only needed for the ops that use them.

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.

Contributors Mishka Group Core
Shahryar Tavakkoli
LEAD
Shahryar Tavakkoli @shahryar-tavakkoli
Elixir · Phoenix · LiveView

Shahryar Tavakkoli writes about Elixir and Phoenix LiveView: Mishka Chelekom releases, GuardedStruct validation, static sites, service workers and SEO.

Comments

Sign in to comment

No comments yet.