๐Ÿ›ก๏ธ 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. โœจ

Hex.pmHex DownloadsLicenseGitHub SponsorsBuy Me a Coffee


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.


๐Ÿ“– Table of contents


๐Ÿ’ญ 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

๐Ÿงช 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
# 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

๐Ÿ”ฎ 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


๐Ÿš€ Installation

Add to your mix.exs:

def deps do
[
{:guarded_struct, "~> 0.1.0"}
]
end

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)
{: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:


๐Ÿชž 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)"]

๐Ÿ”Œ Compatibility

DependencyRequired versionRequired?
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


๐Ÿค– LLM agent skills & usage rules

Ship agent context for Claude Code, Cursor, Copilot, and any skills.sh-compatible runner. Two formats โ€” click to expand.
LayoutForSource of truth
usage-rules.md + usage-rules/*.mdash-project/usage_rules consumersThis repo's root
.claude/skills/*/SKILL.mdskills.sh / Claude Code / Cursor / CopilotThis 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):

SkillWhen it triggers
guarded-structAny use of the library โ€” umbrella skill
guarded-struct-dslfield / sub_field / conditional_field / virtual_field / dynamic_field declarations
guarded-struct-derivederives: strings, SanitizerDerive.sanitize/2
guarded-struct-conditionalconditional_field runtime dispatch + error aggregation
guarded-struct-ashextensions: [GuardedStruct.AshResource], atomic mode, Change wiring
guarded-struct-extensionsuse GuardedStruct.Derive.Extension, custom validators / sanitizers
guarded-struct-apibuilder/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

AreaStatus
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 test

Before opening a PR:

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:

GitHub Sponsors ย ย ย  Buy Me a Coffee

โ˜• 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.