๐ก๏ธ GuardedStruct
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.
๐ 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, 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.
๐ช 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 โ 1,280+ tests including 36 property tests, real Ash integration suite with ETS data layer.
๐ Installation
Add to your mix.exs:
def deps do
[
{:guarded_struct, "~> 0.1.4"}
]
end
spark, splode, telemetry and html_sanitize_ex come with it; they are required dependencies. Elixir ~> 1.17 is the minimum.
Fetch 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)
{: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_url
A 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.
๐ฏ 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 ยท 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.
๐ Compatibility
| Dependency | Required version | Required? |
|---|---|---|
| Elixir | ~> 1.17 |
โ |
| 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 on Elixir < 1.18) |
| 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) |
๐ 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 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:
# 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:
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:
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. 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 | ๐ข 1,280+ 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 test
Before 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:
โ Donate / sponsor: github.com/sponsors/mishka-group ยท buymeacoffee.com/mishkagroup
Sponsorship directly funds maintenance, new features, and documentation. Thank you. ๐
๐ License
Apache License 2.0 โ see LICENSE.
Copyright ยฉ Mishka Group and contributors.