# ๐Ÿ›ก๏ธ 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.pm](https://img.shields.io/hexpm/v/guarded_struct.svg?style=flat-square)](https://hex.pm/packages/guarded_struct) [![Hex Downloads](https://img.shields.io/hexpm/dt/guarded_struct.svg?style=flat-square)](https://hex.pm/packages/guarded_struct) [![License](https://img.shields.io/hexpm/l/guarded_struct.svg?style=flat-square)](https://github.com/mishka-group/guarded_struct/blob/master/LICENSE) [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-mishka--group-ea4aaa?style=flat-square&logo=github)](https://github.com/sponsors/mishka-group) [![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-mishkagroup-ffdd00?style=flat-square&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/mishkagroup)

[!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  

๐Ÿช 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

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


๐Ÿค– 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 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](https://img.shields.io/badge/GitHub_Sponsors-mishka--group-ea4aaa?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/mishka-group) ย ย ย  [![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-mishkagroup-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/mishkagroup) **โ˜• Donate / sponsor:** [github.com/sponsors/mishka-group](https://github.com/sponsors/mishka-group) ยท [buymeacoffee.com/mishkagroup](https://www.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.