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.0-beta. v0.1.0 rewrites 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) validate(string, not_empty, max_len=80) sanitize(slugify)"
# OR
@derives "sanitize(trim, downcase) validate(string, not_empty, max_len=80) sanitize(slugify)"
field :slug, :string - 🧩 Combinators —
optional=wraps any inner ops, passingnilthrough;each=runs inner ops over every element of a list; both nest arbitrarily. - 🎯 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 on Elixir 1.18+. 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 — 740+ tests including 6 property tests, real Ash integration suite with ETS data layer.
Add to your mix.exs:
def deps do
[
{:guarded_struct, "~> 0.1.0"}
]
endFetch 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` (Elixir < 1.18, otherwise built-in JSON works)
{:splode, "~> 0.3"} # for Errors wrapper
{:ash, "~> 3.0"} # for the Ash extension
{:html_sanitize_ex, "~> 1.5"} # for `sanitize(strip_tags, basic_html, html5)`
{:email_checker, "~> 0.2"} # for `validate(email)` (DNS lookup; non-atomic)
{:ex_url, "~> 2.0"} # for `validate(url)` (DNS / port check; non-atomic)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<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.
- 🏃 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.17 |
✅ |
| Spark | ~> 2.7 |
✅ |
| Splode | ~> 0.3 |
✅ (errors module) |
| Telemetry | ~> 1.0 |
✅ |
| html_sanitize_ex | ~> 1.5 |
⚪ optional (sanitize(strip_tags/basic_html/html5)) |
| Jason | ~> 1.4 |
⚪ optional (json: true on Elixir < 1.18) |
| email_checker | ~> 0.2 |
⚪ optional (validate(email) with DNS) |
| ex_url | ~> 2.0 |
⚪ optional (validate(url) with DNS) |
| 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 | 🟢 743+ 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.