v0.0.0 · Apache-2.0 · Compile-time enforced

GUARDED
STRUCT

The GuardedStruct macro builds Elixir structs that come with validation, sanitizing and a constructor.

⚜ Compile-time verifiers
Three verifiers check validators, auto: functions and struct references. A wrong shape is reported by the compiler.
⛨ Parsed at compile time
Derive strings are parsed at compile time, and the derive-extension registry is cached in :persistent_term.
⚔ Ash extension
Atomic mode, Ash.Expr handling, auto-wire, bulk operations and auto-map cascade.
› mix igniter.install guarded_struct
G S ⛨ ⚜ ✦ ✧ • • ✧ ✦
II · Fields & nested structs ━━━ The Inner Sanctum

Fields, nested structs and conditional fields.

Declare a struct once with field, sub_field, conditional_field, virtual_field and dynamic_field, plus pattern-keyed maps.

⚜
field
entity
Declares one field. Type, default, validator and derives all go on one line.
field :email, :string, enforce: true, derives: "validate(email_r)"
hover for an example
⛨
sub_field
entity
A nested struct defined inline, with its own fields, validators and derives.
sub_field :address, :map do
  field :city, :string
end
hover for an example
⚖
conditional_field
entity
One-of variants. The first shape the input passes wins.
conditional_field :payment, any() do
  field :payment, :map, struct: Card
  field :payment, :map, struct: Bank
end
hover for an example
✺
virtual_field
entity
Validated with the rest, then dropped from the struct and never persisted.
virtual_field :password_confirm, :string, derives: "validate(string)"
hover for an example
✦
dynamic_field
entity
A free-form map, kept as sent. String keys never become atoms.
dynamic_field :metadata
hover for an example
♦
pattern-keyed
regex field
A field named with a regex: a map with no fixed keys, every value validated the same way.
field ~r/^[a-z]{2}$/, :string
hover for an example
III · Derive rules ━━━ The Forge

Sanitize and validate with 88 built-in operations.

A derives string on a field lists the sanitize and validate operations it runs. The string is parsed at compile time.

━ Built-in operations · 88
validate sanitize
━ Five ways to write derives

            
            
⚡ Operand parsing happens at compile-time. For the built-in operands the runtime only walks the pre-parsed op lists.
IV · Cross-field rules ━━━ The Watchtower

Rules that look across fields.

auto fills a field from a function call. from, on and domain copy from, or check against, other paths of the same input.

⌬
auto
Fill the field from an MFA call when it is missing, e.g. a fresh UUID for `id`.
auto: {Ecto.UUID, :generate}
↦
from
Copy the value at another path into this field when it is not set.
from: "root::user::id"
◈
on
Conditional rules: accept this field only when another path is present.
on: "root::role"
◆
domain
Cross-field rule: when this field is set, another must be in a list.
domain: "!role=String[admin, owner]"
V · Validation layers ━━━ The Three Gates

Validate in three layers.

Field-level validators, a main_validator/1 over the whole struct and derive rules can all run on one builder/1 call, in that order.

1 · field-level validator:
Inline
2 · main_validator/1
Main
3 · sanitize + validate ops
Derive
VI · Ash extension ━━━ The Royal Bridge

An extension for Ash 3.x resources.

Add GuardedStruct.AshResource to an Ash resource and, once its change is wired in (or auto_wire is on), the create and update actions sanitize and validate your fields.

  • Atomic mode
    Updates stay atomic. Sanitized values go straight into the SQL.
  • Ash.Expr handling
    An Ash.Expr on a guarded field falls back to the imperative path, so the action needs require_atomic? false.
  • Auto-wire
    auto_wire adds the Change for you. No changes block to write.
  • Bulk operations
    Every row of a bulk create or update runs the guarded pipeline.
  • Auto-map cascade
    Nested sub_fields come back as plain maps, ready for :map attributes.

            
          
VII · Runtime API ━━━ The Armory

Build, validate, diff and describe.

Four functions work from the same schema at runtime: build a struct, validate part of a map, diff two values, describe the schema.

⚒
builder
Builds the struct and validates it in one call. Returns {:ok, struct} or {:error, errors}.
MyStruct.builder/1
⚔
validate
Check one value or a partial map against the schema without building a struct.
GuardedStruct.Validate.partial/2
⚖
diff
Compare two guarded values. See what changed, field by field.
GuardedStruct.Diff.diff/2
ℹ
info
Introspect the schema at runtime: fields, types, defaults, validators.
GuardedStruct.Info.describe/1
VIII · Error maps ━━━ The Herald's Standard

One error shape for every failed check.

Failures come back as a list of maps that name the field, the action that failed and a message.

Canonical shape {:error, [%{field, action, message}]}
One map per failure A field that fails two checks gets two maps.
Splode-aware Compatible with Splode for structured exception handling.
i18n-ready Actions are atoms; messages go through GuardedStruct.Messages, so you can translate them.
Provenance Every error names its field and the action that failed.
# A single, predictable shape:
{:error, [
  %{
    field:   :email,
    action:  :email_r,
    message: "Incorrect email in the email field."
  }
]}

# Opt in to Splode:
{:error, errs} = User.builder(params)
GuardedStruct.Errors.from_tuple(errs)
# => %GuardedStruct.Errors.Invalid{errors: [...]}
IX · Telemetry ━━━ The Beacon Towers

Three telemetry events from builder/1.

builder/1 emits :telemetry start, stop and exception events that you can attach handlers to.

1
builder.start
[:guarded_struct, :builder, :start]
2
builder.stop
[:guarded_struct, :builder, :stop]
3
builder.exception
[:guarded_struct, :builder, :exception]
All emitted via :telemetry · attach a handler, pipe to LiveDashboard, ship to your APM.
X · Compile-time checks ━━━ The Wards

Three compile-time verifiers in the build.

Three Spark verifiers check the DSL after the module compiles and report a Spark.Error.DslError, before any data is built.

⚜
VerifyValidatorMFA
Every validator: {Mod, :fn} must export fn/2, checked after compile.
COMPILE-TIME · DslError
⚔
VerifyAutoMFA
Every auto: {Mod, :fn} must point to a function that exists.
COMPILE-TIME · DslError
⛨
VerifyNoStructCycles
struct: and structs: references may never loop back on themselves.
COMPILE-TIME · DslError
XI · Performance ━━━ The Engines

Derives are parsed at compile-time.

Derive strings are parsed once, at compile time, into operation lists that builder/1 reads on every call.

Compile-time baked dispatch
Every derives string is parsed and checked once, when the module compiles, and stored as op lists. builder/1 only walks them. Nothing is parsed per call.
persistent_term cache
The registry of your custom derive extensions is cached in :persistent_term, resolved once, not on every call.
builder/1 · at runtime
~3µs
microseconds per call · 4 fields, sanitize + validate, valid input
~3 µs invalid input
~7 µs nested + conditional
~4 ms build cost per module

Measured on an Apple M4 · Elixir 1.20.3 / OTP 29 · guarded_struct 0.1.3. Runtime: mean time per call over batches of 500,000 calls, median of 7 batches. Build cost: the time a 4-field guarded struct adds per module to a parallel mix compile of 100 modules (median of 7 builds; ~19 ms when one module compiles alone), paid once when your project builds, never per call. Your hardware and rules will differ.

XII · Security ━━━ The Gatehouse

Safe with untrusted input.

Atoms are never garbage collected, so input keys never create new atoms.

⛨
Atom-attack safe
Input keys never create atoms. A key you did not declare stays a string.
🗝
Authorized fields
authorized_fields: true rejects every key you did not declare, one error each.
📜
SECURITY.md
Supported versions and a private address for reporting a vulnerability.
XIII · Tooling ━━━ The Forging Yard

Editor and tooling support.

⌨
Autocomplete
Editor autocomplete inside guardedstruct, via Spark's ElixirSense plugin.
✎
mix format
Formatter rules ship with the lib. No bikeshed wars.
📖
Cheat sheets
Generated DSL reference for every entity and its options.
📓
LiveBook tour
Interactive notebook: run, mutate, validate in your browser.
XIV · AI agent context ━━━ The Scriptorium

Your coding agent reads the rules.

A usage-rules.md file and seven SKILL.md files ship with the library, so a coding agent can learn its API.

usage-rules.md
Drop-in rules file. mix usage_rules.sync folds it into your AGENTS.md, so any agent learns the library.
.claude/skills
Seven SKILL.md files, one per subsystem, for Claude Code, Cursor, Copilot or any skills.sh runner.
# Pull the rules into your project:
$ mix usage_rules.sync
↳ reads :usage_rules from mix.exs
↳ gathers usage-rules.md from each listed dep
↳ writes AGENTS.md

# The seven skills ship as plain folders — copy them:
$ mkdir -p .claude/skills
$ cp -R deps/guarded_struct/.claude/skills/* .claude/skills/

# Now your agent answers:
Q: "What does MyApp.User.profile accept?"
A: a :map sub_field with one field, :bio with "validate(string, max_len=200)"
XV · Integrations ━━━ The Trade Roads

Works with optional integrations.

optional
JSON encoder
json: true derives a Jason or built-in JSON encoder.
optional
igniter
One-command setup: mix igniter.install guarded_struct.
optional
i18n
Your own message_backend translates errors, e.g. with Gettext.
optional
email_checker
Turns on validate(email), checked against DNS.
XVI · Testing ━━━ The Proving Ground

Unit, property and Ash integration tests.

Unit tests, StreamData property checks and an integration suite that runs against real Ash resources.

0
Unit tests
passing on every commit
0
Property checks
StreamData generators
Ash
Integration suite
real Ash 3.x resources on ETS
$ mix test
.................................................................
.................................................................
Finished in 7.6 seconds (6.2s async, 1.4s sync)
Result: 1022 passed (36 properties, 986 tests)

Output of one real run. Median of 8 runs of mix test on an Apple M4, Elixir 1.20.3 / OTP 29, guarded_struct 0.1.3: 3.8 seconds.

XVII · Get started ━━━ The Heralds' Call

Add Guarded Struct to your project.

Add the dependency, follow the LiveBook tour, read the docs or give your coding agent the rules file.

Newsletter · monthly

Get release notes by email.

One email a month with release notes, usage patterns and important changes to Guarded Struct. No spam, and you can unsubscribe at any time.

⚜ once a month⚜ no spam⚜ unsubscribe anytime