The GuardedStruct macro builds Elixir structs that come with validation, sanitizing and a constructor.
Declare a struct once with field, sub_field, conditional_field, virtual_field and dynamic_field, plus pattern-keyed maps.
field :email, :string, enforce: true, derives: "validate(email_r)"
sub_field :address, :map do field :city, :string end
conditional_field :payment, any() do field :payment, :map, struct: Card field :payment, :map, struct: Bank end
virtual_field :password_confirm, :string, derives: "validate(string)"
dynamic_field :metadata
field ~r/^[a-z]{2}$/, :string
A derives string on a field lists the sanitize and validate operations it runs. The string is parsed at compile time.
auto fills a field from a function call. from, on and domain copy from, or check against, other paths of the same input.
auto: {Ecto.UUID, :generate}
from: "root::user::id"
on: "root::role"
domain: "!role=String[admin, owner]"
Field-level validators, a main_validator/1 over the whole struct and derive rules can all run on one builder/1 call, in that order.
Add GuardedStruct.AshResource to an Ash resource and, once its change is wired in (or auto_wire is on), the create and update actions sanitize and validate your fields.
Four functions work from the same schema at runtime: build a struct, validate part of a map, diff two values, describe the schema.
Failures come back as a list of maps that name the field, the action that failed and a message.
# A single, predictable shape: {:error, [ %{ field: :email, action: :email_r, message: "Incorrect email in the email field." } ]} # Opt in to Splode: {:error, errs} = User.builder(params) GuardedStruct.Errors.from_tuple(errs) # => %GuardedStruct.Errors.Invalid{errors: [...]}
builder/1 emits :telemetry start, stop and exception events that you can attach handlers to.
Three Spark verifiers check the DSL after the module compiles and report a Spark.Error.DslError, before any data is built.
Derive strings are parsed once, at compile time, into operation lists that builder/1 reads on every call.
Measured on an Apple M4 · Elixir 1.20.3 / OTP 29 · guarded_struct 0.1.3. Runtime: mean time per call over batches of 500,000 calls, median of 7 batches. Build cost: the time a 4-field guarded struct adds per module to a parallel mix compile of 100 modules (median of 7 builds; ~19 ms when one module compiles alone), paid once when your project builds, never per call. Your hardware and rules will differ.
Atoms are never garbage collected, so input keys never create new atoms.
A usage-rules.md file and seven SKILL.md files ship with the library, so a coding agent can learn its API.
# Pull the rules into your project: $ mix usage_rules.sync ↳ reads :usage_rules from mix.exs ↳ gathers usage-rules.md from each listed dep ↳ writes AGENTS.md # The seven skills ship as plain folders — copy them: $ mkdir -p .claude/skills $ cp -R deps/guarded_struct/.claude/skills/* .claude/skills/ # Now your agent answers: Q: "What does MyApp.User.profile accept?" A: a :map sub_field with one field, :bio with "validate(string, max_len=200)"
Unit tests, StreamData property checks and an integration suite that runs against real Ash resources.
$ mix test ................................................................. ................................................................. Finished in 7.6 seconds (6.2s async, 1.4s sync) Result: 1022 passed (36 properties, 986 tests)
Output of one real run. Median of 8 runs of mix test on an Apple M4, Elixir 1.20.3 / OTP 29, guarded_struct 0.1.3: 3.8 seconds.
Add the dependency, follow the LiveBook tour, read the docs or give your coding agent the rules file.
One email a month with release notes, usage patterns and important changes to Guarded Struct. No spam, and you can unsubscribe at any time.