[!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 inCHANGELOG.md.
๐ Table of contents
- Why GuardedStruct?
- Highlights
- Installation
- Quick start
- Atomic mode (Ash)
- Introspection
- Architecture
- Compatibility
- Documentation
- LLM agent skills & usage rules
- Status & roadmap
- Contributing
- Funding & sponsorship
- License
๐ญ Why GuardedStruct?
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. ๐
โจ Highlights
๐๏ธ Core DSL
-
๐งฑ
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.
๐งช Derive mini-language
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 -
๐งผ Sanitize ops โ
trim,upcase,downcase,capitalize,strip_tags,basic_html,html5,tag, plus user-defined custom ops. -
โ
Validate ops โ
string,integer,float,boolean,atom,list,map,tuple,record,not_empty,not_empty_string,max_len,min_len,max,min,equal,uuid,email,email_r,url,url_r,ipv4,ipv6,regex,enum,datetime,date,time,geo,location, plus user-defined. -
๐ฏ 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.
๐ช Custom validators / sanitizers (Derive.Extension)
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
end
Register 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.
๐ Ash integration
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.
๐ฎ Standalone validation API
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 check๐ก Telemetry
Every top-level builder/1 emits [:guarded_struct, :builder, :start | :stop | :exception]. Attach a handler for logging, metrics, tracing โ no manual instrumentation needed.
๐ช Introspection (GuardedStruct.Info)
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)}
end
Gives Splode.traverse_errors/2, to_class/1, JSON-serializable errors.
๐ค JSON encoding (opt-in)
guardedstruct json: true do
field :id, :string
end
Auto-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.
๐ Cross-cutting
-
๐ 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.
๐ Installation
Add to your mix.exs:
def deps do
[
{:guarded_struct, "~> 0.1.0"}
]
endFetch and compile:
mix deps.get
mix compile
Upgrading from 0.0.x? Existing code keeps working unchanged โ see CHANGELOG.md for every change in v0.1.0.
Optional deps
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)๐ฏ Quick start
๐ A struct
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: "..."}}๐ณ Nested + conditional
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}, ...}}๐ช Custom validators / sanitizers
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
end๐ Ash integration
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, 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", ...}}โ๏ธ Atomic mode (Ash)
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.
๐ช Introspection
# 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, ...}]๐๏ธ Architecture
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.
๐ Compatibility
| 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) |
๐ Documentation
- ๐ 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
๐ค LLM agent skills & usage rules
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`](https://github.com/ash-project/usage_rules) consumers | This repo's root | | `.claude/skills/*/SKILL.md` | skills.sh / Claude Code / Cursor / Copilot | This repo's `.claude/skills/` | ### Option A โ pull into your project via `usage_rules` Add the dev dep and a `:usage_rules` block to your `mix.exs`: ```elixir # 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]}] end ``` Install and sync โ these are the only commands you need: ```sh 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 term ``` `mix 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: ```elixir 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. ### Option B โ copy the skills directly 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. ### Option C โ read inline Start with [`usage-rules.md`](./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.๐ฃ๏ธ Status & roadmap
| 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.
๐ค Contributing
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.
๐ Funding & sponsorship
GuardedStruct is open-source software developed by Mishka Group. If your team or company benefits from this work, please consider supporting continued development:
Sponsorship directly funds maintenance, new features, and documentation. Thank you. ๐
๐ License
Apache License 2.0 โ see LICENSE.
Copyright ยฉ Mishka Group and contributors.