Add project-specific sanitize(<op>) / validate(<op>) atoms via a Spark DSL.
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, "-")
end
end
endThe extension module exposes:
__validators__/0,__sanitizers__/0— atom lists for compile-time checks.__validate__(op, input, field)— runtime dispatcher; returns:__not_found__when the op isn't declared.__sanitize__(input, op)— runtime dispatcher (pipe-friendly arg order).__derive_extension__?/0—true, so the registry filter accepts it.
Registering an extension
Global (Application config)
# config/config.exs
config :guarded_struct, derive_extensions: [MyApp.Derives]Resolved at boot, cached in :persistent_term. Invalidates automatically when
the config changes; call GuardedStruct.Derive.Extension.clear_cache/0 from
test setup if you mutate the env at runtime.
Per-module
use GuardedStruct, derive_extensions: [MyApp.Derives]
# or compose with global config:
use GuardedStruct, derive_extensions: [MyApp.Derives, :config]Resolution rules:
| Opt | Result |
|---|---|
nil | Global only. |
[] | No extensions (opt-out). |
[A, B] | [A, B] only — global ignored. |
[:config, A] | global ++ [A] (global wins on op-name collisions). |
[A, :config] | [A] ++ global (A wins). |
[A, :config, B] | [A] ++ global ++ [B]. |
Validator return contract
The function passed to validator/2 may return:
true— input passes, value unchanged.false— input fails with a generic message.{:error, field, action, message}— explicit error.- Any other value — used as the coerced output (replaces the input).
Sanitizer return contract
Return value replaces the input. Sanitizers run before validators.
Compile-time shadow warning
Declaring validator :string / sanitizer :trim shadows a built-in. The
Codegen transformer returns {:warn, dsl_state, warnings} from its
transform/1 callback so the message reaches the user through Spark's own
diagnostic channel and points at the offending entity's source line. The
internal Derive registry decides what counts as built-in.