Skip to content

About

GuardedStruct macro allows to build Structs that provide you with a number of important options Validation, Sanitizing, Constructor

Topics

Resources

Security policy

Stars

34 stars

Watchers

2 watching

Forks

Repository files navigation

🛡️ 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

  • 🧱 field — typed, optionally enforced, with default, sanitize+validate derive, auto-fill MFA, per-field validator, cross-field on:/from:/domain:.
  • 🌲 sub_field — recursive nested struct, any depth, generates real submodules with their own builder/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 from defstruct (classic password_confirm use case).
  • 🌀 dynamic_field — free-form map with passthrough; atom-attack-safe (string keys stay strings, no String.to_atom of attacker input).
  • 🔣 Pattern-keyed maps — field whose 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, passing nil through; each= runs inner ops over every element of a list; both nest arbitrarily and accept ops from your Derive.Extension modules.
  • 🎯 All ops parsed at compile time — runtime reads pre-built op-maps from __fields__/0; zero Code.eval_string on the hot path.
  • 🧰 @derives decorator — alternative to inline derives: 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__/1 into the Ash changeset pipeline.
  • ⚡ auto_wire true — Spark transformer injects the change for you; no changes do ... end block needed.
  • 📦 batch_change/3 — Ash.bulk_create/3 and Ash.bulk_update/3 (with strategy: :stream) work end-to-end.
  • 🌊 Auto-map cascade — every sub_field returns a plain map at every depth (matches Ash's :map attribute type).
  • ⚛️ Atomic-safe by default — Change.atomic/3 runs the pipeline on plain literals and returns {:atomic, sanitized_map}; update actions stay atomic without require_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. No-op if neither is present.

🌍 Cross-cutting

  • 🌐 i18n — every error message resolves through GuardedStruct.Messages; override callbacks to translate.
  • 🛡️ Atom-attack safe — dynamic_field and pattern-keyed maps never String.to_atom user 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.20 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` through Jason (the built-in JSON works without it)
{: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__/1 pipeline (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. from Ash.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)"]
Loading
  • 🧠 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__/1 into changeset attributes.

🔌 Compatibility

Dependency Required version Required?
Elixir ~> 1.20 ✅
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 uses it when present)
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:

  • ✅ mix test — full suite green (mix test --max-failures 1 for fail-fast)
  • ✅ mix lint — spark.formatter + format both 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.

About

GuardedStruct macro allows to build Structs that provide you with a number of important options Validation, Sanitizing, Constructor

Topics

Resources

Security policy

Stars

34 stars

Watchers

2 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages