# ๐Ÿ›ก๏ธ 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.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) ```elixir 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) ```elixir 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_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.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__/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)"]
  • ๐Ÿง  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

DependencyRequired versionRequired?
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`](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

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๐ŸŸข 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:

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