๐Ÿ›ก๏ธ 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 Hex Downloads License GitHub Sponsors Buy Me a Coffee


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?

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, 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 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.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:


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

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


๐Ÿค– 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:

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.