Build Elixir structs with validation, sanitization, nested sub-structs, conditional fields, pattern-keyed maps, and a first-class Ash extension — declared once, parsed at compile time, validated on every build. ✨
Note
Status — 0.1.4. v0.1.0 rewrote the macro core on Spark. Every existing 0.0.x API keeps working unchanged. Track every change in CHANGELOG.md.
- Why GuardedStruct?
- Highlights
- Installation
- Quick start
- Atomic mode (Ash)
- Introspection
- Architecture
- Compatibility
- Documentation
- LLM agent skills & usage rules
- Status & roadmap
- Contributing
- Funding & sponsorship
- License
Defining a "good" struct in Elixir means doing the same boilerplate every time: defstruct, @enforce_keys, a @type t(), a constructor, per-field validation, sanitization, default values, nested structs, error messages, i18n. Each surface ends up subtly different across projects.
GuardedStruct collapses that into a DSL. One guardedstruct do ... end block declares fields, validation rules, sanitization, nested sub-structs, conditional dispatch, custom callbacks. The library generates defstruct, @type t(), a builder/1,2 constructor, introspection functions, and a configurable error pipeline — all parsed once at compile time so the runtime hot path is small.
defmodule User do
use GuardedStruct
guardedstruct do
field :name, :string, enforce: true,
derives: "sanitize(trim, capitalize) validate(string, max_len=80)"
field :email, :string, enforce: true,
derives: "sanitize(trim, downcase) validate(email_r)"
field :age, :integer,
derives: "validate(integer, min_len=0, max_len=120)"
field :role, :string, default: "user",
derives: "validate(enum=String[admin::user::guest])"
end
end
User.builder(%{
name: " alice ",
email: "ALICE@EXAMPLE.COM",
age: 30
})
# => {:ok, %User{name: "Alice", email: "alice@example.com", age: 30, role: "user"}}
User.builder(%{name: "x", email: "bad", age: -5})
# => {:error, [
# %{field: :email, action: :email_r, message: "..."},
# %{field: :age, action: :min_len, message: "..."}
# ]}That's the full surface. No defstruct, no @enforce_keys, no validator boilerplate, no constructor. 🚀
- 🧱
field— typed, optionally enforced, with default, sanitize+validate derive, auto-fill MFA, per-field validator, cross-fieldon:/from:/domain:. - 🌲
sub_field— recursive nested struct, any depth, generates real submodules with their ownbuilder/1. - 🎭
conditional_field— sum-type-like dispatch: same field name resolves to different shapes based on the input (string OR struct OR list). Nestable to arbitrary depth. - 👻
virtual_field— validated through the full pipeline but excluded fromdefstruct(classicpassword_confirmuse case). - 🌀
dynamic_field— free-form map with passthrough; atom-attack-safe (string keys stay strings, noString.to_atomof attacker input). - 🔣 Pattern-keyed maps —
fieldwhose name is a regex declares a map shape with no fixed keys; uniform per-value validation. - 🧬 Erlang Records —
validate(record=tag)accepts tagged tuples.
field :slug, :string,
derives: "sanitize(trim, downcase, slugify) validate(string, not_empty, max_len=80)"
# OR
@derives "sanitize(trim, downcase, slugify) validate(string, not_empty, max_len=80)"
field :slug, :string Every sanitize(...) group runs before every validate(...) group, in the order written, wherever the groups sit in the string. Operands are read strictly: a negative number (min_len=-2), a float, a boolean or equal=Type::value is parsed, and anything the parser can't read is a compile-time Spark.Error.DslError instead of being dropped.
- 🧩 Combinators —
optional=wraps any inner ops, passingnilthrough;each=runs inner ops over every element of a list; both nest arbitrarily and accept ops from yourDerive.Extensionmodules. - 🎯 All ops parsed at compile time — runtime reads pre-built op-maps from
__fields__/0; zeroCode.eval_stringon the hot path. - 🧰
@derivesdecorator — alternative to inlinederives:for keeping fields short.
# Combinators in real fields:
field :tags, {:array, :string},
derives: "sanitize(each=[trim, downcase], reject_empty, uniq) validate(each=[string, hostname])"
field :nickname, :string,
derives: "sanitize(trim) validate(optional=[string, max_len=24])"🧼 All built-in sanitize ops (click to expand)
trim, upcase, downcase, capitalize, strip_tags, basic_html, html5, markdown_html, tag,
string_float, string_integer, squish, no_control, no_zero_width,
uniq, compact, reject_empty, sort,
clamp=[min,max], default_when_nil=v, default_when_empty=v, each=[ops],
plus user-defined custom ops via GuardedStruct.Derive.Extension.
✅ All built-in validate ops (click to expand)
Types: string, integer, float, boolean, atom, list, map, tuple, record, bitstring, function, pid, port, reference, struct, exception, nil_value, not_nil_value, number, queue.
Emptiness / size: not_empty, not_empty_string, not_flatten_empty, not_flatten_empty_item, max_len, min_len.
Format: uuid, email, email_r, url, tell, geo_url, location, ipv4, regex, datetime, date, range, enum, equal, string_float, string_integer, some_string_float, some_string_integer, string_boolean, username, full_name.
Named formats: slug, hostname, port_number, hex_color, semver.
Composition: either=[ops], optional=[ops], each=[ops], custom=[Mod, fn], plus user-defined.
defmodule MyApp.Derives do
use GuardedStruct.Derive.Extension
derives do
validator :slug, fn input ->
is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
end
sanitizer :slugify, fn input ->
input |> String.downcase() |> String.replace(~r/[^a-z0-9]+/u, "-")
end
end
endRegister globally (config :guarded_struct, derive_extensions: [MyApp.Derives]) or per-module (use GuardedStruct, derive_extensions: [MyApp.Derives]). Per-module lists support a :config sentinel for in-position merge with the global registry. Compile-time shadow warnings if a custom op-name collides with a built-in.
defmodule MyApp.User do
use Ash.Resource, extensions: [GuardedStruct.AshResource]
guardedstruct do
auto_wire true
field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
end
attributes do
uuid_primary_key :id
attribute :email, :string, allow_nil?: false, public?: true
end
actions do
defaults [:read, :destroy]
create :create, accept: [:email]
end
end- 🌉
GuardedStruct.AshResource.Change— bridges__guarded_change__/1into the Ash changeset pipeline. - ⚡
auto_wire true— Spark transformer injects the change for you; nochanges do ... endblock needed. - 📦
batch_change/3—Ash.bulk_create/3andAsh.bulk_update/3(withstrategy: :stream) work end-to-end. - 🌊 Auto-map cascade — every
sub_fieldreturns a plain map at every depth (matches Ash's:mapattribute type). - ⚛️ Atomic-safe by default —
Change.atomic/3runs the pipeline on plain literals and returns{:atomic, sanitized_map}; update actions stay atomic withoutrequire_atomic? false.
GuardedStruct.Validate.run("validate(email_r)", "alice@x.io")
# => {:ok, "alice@x.io"}
GuardedStruct.Validate.field(User, :email, "bad")
# => {:error, [%{field: :email, action: :email_r, ...}]}
GuardedStruct.Validate.partial(User, %{name: "Alice"})
# => {:ok, %{name: "Alice"}} # missing fields skipped, no enforce checkEvery top-level builder/1 emits [:guarded_struct, :builder, :start | :stop | :exception]. Attach a handler for logging, metrics, tracing — no manual instrumentation needed.
GuardedStruct.Info.describe(User)
# => %{module: User, keys: [...], enforce_keys: [...],
# fields: [%{name: :email, kind: :field, ...}, ...],
# options: %{enforce: true, json: false, ...}}
GuardedStruct.Info.field_kind(User, :email) #=> :field
GuardedStruct.Info.enforce?(User, :email) #=> true
GuardedStruct.Info.sub_module(User, :address) #=> User.Address
GuardedStruct.Info.conditional_children(User, :billing)🛡️ Errors as Splode exceptions (opt-in)
case User.builder(input) do
{:ok, _} = ok -> ok
{:error, errs} -> {:error, GuardedStruct.Errors.from_tuple(errs)}
endGives Splode.traverse_errors/2, to_class/1, JSON-serializable errors.
📤 JSON encoding (opt-in)
guardedstruct json: true do
field :id, :string
endAuto-derives Jason.Encoder when :jason is in deps, falling back to the built-in JSON.Encoder. No-op if neither is present.
- 🌐 i18n — every error message resolves through
GuardedStruct.Messages; override callbacks to translate. - 🛡️ Atom-attack safe —
dynamic_fieldand pattern-keyed maps neverString.to_atomuser input. - 🧪 Property-based tested — 1,280+ tests including 36 property tests, real Ash integration suite with ETS data layer.
Add to your mix.exs:
def deps do
[
{:guarded_struct, "~> 0.1.4"}
]
endspark, splode, telemetry and html_sanitize_ex come with it; they are required dependencies. Elixir ~> 1.20 is the minimum.
Fetch and compile:
mix deps.get
mix compileUpgrading from 0.0.x? Existing code keeps working unchanged — see CHANGELOG.md for every change in v0.1.0.
Pull in only what you need:
{:jason, "~> 1.4"} # for `json: true` through Jason (the built-in JSON works without it)
{:ash, "~> 3.0"} # for the Ash extension
{:email_checker, "~> 0.2"} # for `validate(email)` (DNS lookup; non-atomic)
{:ex_url, "~> 2.0"} # for `validate(tell)`, `validate(geo_url)` and `validate(location)`
{:ex_phone_number, "~> 0.4"} # for `validate(tell=98)` (a country code), together with ex_urlA package is looked up when the op runs, so the order of the deps does not matter, in mix.exs or in Mix.install. An op whose package is missing returns an error that names the package to add.
defmodule Order do
use GuardedStruct
guardedstruct enforce: true do
field :id, :string, auto: {Ecto.UUID, :generate}
field :total, :integer, derives: "validate(integer, min_len=0)"
field :currency, :string, default: "USD",
derives: "validate(enum=String[USD::EUR::GBP::JPY])"
field :placed_at, :string, derives: "validate(datetime)"
end
end
Order.builder(%{total: 9_900, placed_at: "2026-05-14T10:00:00Z"})
# => {:ok, %Order{id: "a-uuid", total: 9900, currency: "USD", placed_at: "..."}}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: "validate(email_r)"
field :role, :string, default: "owner"
end
# Same field name resolves to either a string preset OR a detailed map
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", owner: %{email: "z@a.io"}, plan: "pro"})
# => {:ok, %Account{plan: "pro", ...}}
Account.builder(%{name: "Acme", owner: %{email: "z@a.io"},
plan: %{tier: "custom", seats: 50}})
# => {:ok, %Account{plan: %Account.Plan1{tier: "custom", seats: 50}, ...}}defmodule MyApp.Derives do
use GuardedStruct.Derive.Extension
derives do
validator :slug, 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
validator :positive_int, fn n -> is_integer(n) and n > 0 end
end
end
# Register globally:
# config :guarded_struct, derive_extensions: [MyApp.Derives]
defmodule Post do
use GuardedStruct
guardedstruct do
field :slug, :string, derives: "sanitize(slugify) validate(slug)"
field :views, :integer, derives: "validate(positive_int)"
end
enddefmodule MyApp.User do
use Ash.Resource, 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]
update :update do
accept [:email, :nickname]
end
end
end
MyApp.User
|> Ash.Changeset.for_create(:create, %{email: " Alice@X.IO "})
|> Ash.create()
# => {:ok, %MyApp.User{email: "alice@x.io", ...}}GuardedStruct.AshResource.Change is atomic-safe by default. There's no flag to flip and no require_atomic? false to add — update and destroy actions run as single-statement SQL with sanitized values.
guardedstruct do
auto_wire true
field :email, :string, derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
field :age, :integer, derives: "validate(integer, min_len=0, max_len=150)"
field :role, :string, derives: "validate(enum=String[admin::user::guest])"
field :tenant_id, :string, derives: "validate(uuid)"
end
# Update goes through atomic/3 — pipeline runs in Elixir on the plain
# literal input, sanitized value is substituted into the UPDATE SQL.
record
|> Ash.Changeset.for_update(:update, %{email: " New@X.IO "})
|> Ash.update()
# => {:ok, %{email: "new@x.io", ...}}How it works. Change.atomic/3 reads changeset.attributes and changeset.atomics, detects whether any atomic value is an Ash.Expr, and:
- if every value is a plain literal → runs the full
__guarded_change__/1pipeline (sanitize → validate → derive →auto:→ main_validator) and returns{:atomic, sanitized_map}for Ash to substitute into the SQL, - if any value is an
Ash.Expr(e.g. fromAsh.Changeset.atomic_update(record, :counter, expr(counter + 1))) → returns{:not_atomic, reason}and Ash falls back to the imperative path. This is rare in practice; 99% of changesets pass plain values.
# Full dump in one call
GuardedStruct.Info.describe(MyApp.User)
# %{
# module: MyApp.User,
# path: [], key: :root, shape: :struct,
# keys: [:email, :nickname], enforce_keys: [:email],
# conditional_keys: [],
# options: %{enforce: true, json: false, ...},
# fields: [
# %{name: :email, kind: :field, enforce?: true,
# type: "String.t()", derive: "...", auto: nil, ...},
# ...
# ]
# }
# Field-level helpers
GuardedStruct.Info.field_kind(MyApp.User, :email) #=> :field
GuardedStruct.Info.enforce?(MyApp.User, :email) #=> true
GuardedStruct.Info.virtual?(MyApp.User, :password_confirm) #=> true
GuardedStruct.Info.field_derives(MyApp.User, :email)
#=> "sanitize(trim, downcase) validate(email_r)"
# Collections by kind
GuardedStruct.Info.sub_fields(MyApp.User) #=> [:address]
GuardedStruct.Info.virtual_fields(MyApp.User) #=> [:password_confirm]
GuardedStruct.Info.conditional_fields(MyApp.User) #=> [:plan]
# Navigation
GuardedStruct.Info.sub_module(MyApp.User, :address)
#=> MyApp.User.Address
GuardedStruct.Info.conditional_children(MyApp.User, :plan)
#=> [%{kind: :field, ...}, %{kind: :sub_field, ...}]flowchart TD
User["<b>guardedstruct do ... end</b><br/>user-facing DSL block"]
Spark["<b>Spark.Dsl.Extension</b><br/>parses entities + section opts"]
User --> Spark
Spark --> Transformers["<b>Transformers</b><br/>ParseDerive · ParseCoreKeys · ParseDomain<br/>GenerateBuilder · GenerateSubFieldModules<br/>GenerateAshValidator · AutoWireAshChange"]
Spark --> Verifiers["<b>Verifiers</b><br/>VerifyValidatorMFA · VerifyAutoMFA<br/>VerifyNoStructCycles"]
Spark --> AsyncCompile["<b>Async submodule compile</b><br/>Spark.Dsl.Transformer.async_compile<br/>for sub_field branches"]
Transformers --> Fields["<b>__fields__/0</b> · <b>__information__/0</b><br/>introspection metadata<br/>(read by GuardedStruct.Info)"]
Verifiers --> Fields
AsyncCompile --> Fields
Fields --> Runtime["<b>Runtime pipeline</b><br/>sanitize → validate → derive → main_validator"]
Runtime --> Standalone["<b>builder/1,2</b><br/>{:ok, %Struct{}}<br/>or {:error, [%{field, action, message}]}"]
Runtime --> AshBridge["<b>__guarded_change__/1</b><br/>+ GuardedStruct.AshResource.Change<br/>(bridges to Ash changeset pipeline)"]
- 🧠 DSL layer — Spark sections + entities define
field,sub_field,conditional_field,virtual_field,dynamic_field. Every op-string parsed at compile time. - 🔧 Transformers — codegen for
defstruct/builder/keys/__information__/__fields__, async sub_field submodule generation, derive parsing, core-key parsing, Ash-variant codegen, auto-wire injection. - 🔍 Verifiers — validator MFAs exist, auto MFAs exist, no struct cycles. A verifier only warns at compile time; the module still compiles.
- 🏃 Runtime — receives a map, walks pre-parsed op-lists per field, hands back
{:ok, %Struct{}}or{:error, [%{field, action, message}]}. The Ash bridge routes the same pipeline through__guarded_change__/1into changeset attributes.
| Dependency | Required version | Required? |
|---|---|---|
| Elixir | ~> 1.20 |
✅ |
| Spark | ~> 2.7 |
✅ |
| Splode | ~> 0.3 |
✅ (errors module) |
| Telemetry | ~> 1.0 |
✅ |
| html_sanitize_ex | ~> 1.5 |
✅ (sanitize(strip_tags/basic_html/html5)) |
| Jason | ~> 1.4 |
⚪ optional (json: true uses it when present) |
| email_checker | ~> 0.2 |
⚪ optional (validate(email) with DNS) |
| ex_url | ~> 2.0 |
⚪ optional (validate(tell), validate(geo_url), validate(location)) |
| ex_phone_number | ~> 0.4 |
⚪ optional (validate(tell=98), with ex_url) |
| Ash | ~> 3.0 |
⚪ optional (for the Ash.Resource extension) |
- 📖 API docs — hexdocs.pm/guarded_struct
- 📘 LiveBook walkthrough —
guidance/guarded-struct.livemd— runnable end-to-end examples - 📜 Changelog —
CHANGELOG.md - 🔐 Security policy —
SECURITY.md— supported versions + how to report a vulnerability - 🧱 DSL reference — auto-generated cheat sheets in
documentation/dsls/(published to hexdocs) - 📰 Blog post — Consolidating Input and Output Validation and Sanitization in Elixir with GuardedStruct library
Ship agent context for Claude Code, Cursor, Copilot, and any skills.sh-compatible runner. Two formats — click to expand.
| Layout | For | Source of truth |
|---|---|---|
usage-rules.md + usage-rules/*.md |
ash-project/usage_rules consumers |
This repo's root |
.claude/skills/*/SKILL.md |
skills.sh / Claude Code / Cursor / Copilot | This repo's .claude/skills/ |
Add the dev dep and a :usage_rules block to your mix.exs:
# mix.exs
def project do
[
...,
usage_rules: [
file: "AGENTS.md", # or "CLAUDE.md"
usage_rules: [:guarded_struct], # inline our usage-rules
skills: [
location: ".claude/skills",
package_skills: [:guarded_struct] # pull our SKILL.md files in
]
]
]
end
defp deps do
[{:usage_rules, "~> 1.1", only: [:dev]}]
endInstall and sync — these are the only commands you need:
mix deps.get # pull :usage_rules
mix usage_rules.sync # generate AGENTS.md + .claude/skills/
mix usage_rules.sync --check # verify in CI nothing has drifted
mix usage_rules.search_docs "atomic" # search package docs for a termmix usage_rules.sync reads :usage_rules from mix.exs, gathers
usage-rules.md (and any usage-rules/*.md) from every listed dep, writes
the consolidated AGENTS.md, and drops one SKILL.md per package into
.claude/skills/. Re-run after any mix deps.update.
Sub-rules are addressable by name in the usage_rules: list:
usage_rules: [
"guarded_struct:dsl", # just the DSL doc
"guarded_struct:ash", # just the Ash integration
"guarded_struct:derive", # just the derive op reference
"guarded_struct:errors" # just the error-shape contract
]Full list lives in this repo's usage-rules/ directory.
If you don't use usage_rules, copy any of these directories into your project's
.claude/skills/ (or wherever your agent runner looks):
| Skill | When it triggers |
|---|---|
guarded-struct |
Any use of the library — umbrella skill |
guarded-struct-dsl |
field / sub_field / conditional_field / virtual_field / dynamic_field declarations |
guarded-struct-derive |
derives: strings, SanitizerDerive.sanitize/2 |
guarded-struct-conditional |
conditional_field runtime dispatch + error aggregation |
guarded-struct-ash |
extensions: [GuardedStruct.AshResource], atomic mode, Change wiring |
guarded-struct-extensions |
use GuardedStruct.Derive.Extension, custom validators / sanitizers |
guarded-struct-api |
builder/1, Validate, Diff, Info, telemetry |
Each skill is a single SKILL.md with YAML frontmatter (name, description)
followed by markdown. The descriptions are written with concrete trigger
signals (module names, function calls, error atoms) so agents auto-load the
right skill without manual invocation.
Start with usage-rules.md. It's < 100 lines, links every
sub-topic, and pins down the universal contracts (error shape, generated
module surface, compile-time guarantees) every consumer must know.
| Area | Status |
|---|---|
0.1.0 rewrite on Spark |
🟢 Shipped |
Backward compatibility with 0.0.x |
🟢 Drop-in — every 0.0.x API preserved |
Nested conditional_field (closes #7, #8, #25) |
🟢 Shipped |
| Pattern-keyed maps (closes #11) | 🟢 Shipped |
virtual_field / dynamic_field (closes #5) |
🟢 Shipped |
Standalone Validate API (closes #2) |
🟢 Shipped |
| Erlang Records (closes #6) | 🟢 Shipped |
| Custom validators via Spark DSL | 🟢 Shipped |
| Ash extension + auto-wire + atomic mode | 🟢 Shipped |
| Test coverage | 🟢 1,280+ tests, real Ash integration suite |
1.0.0 release |
🟢 Shipped |
Breaking changes will be flagged in the CHANGELOG.
Issues, PRs, and design discussions are welcome. 💬
git clone https://github.com/mishka-group/guarded_struct.git
cd guarded_struct
mix deps.get
mix testBefore opening a PR:
- ✅
mix test— full suite green (mix test --max-failures 1for fail-fast) - ✅
mix lint—spark.formatter+formatboth pass - ✅
mix cheat— regenerate DSL cheat sheets if you touched entities
For larger feature work, please open an issue first so we can align on the design.
GuardedStruct is open-source software developed by Mishka Group. If your team or company benefits from this work, please consider supporting continued development:
☕ Donate / sponsor: github.com/sponsors/mishka-group · buymeacoffee.com/mishkagroup
Sponsorship directly funds maintenance, new features, and documentation. Thank you. 💚
Apache License 2.0 — see LICENSE.
Copyright © Mishka Group and contributors.