Gladius
Parse, don't validate.conform/2 returns a shaped value on success — coercions applied, data restructured — not just true. Specs are composable structs, not modules. Write a spec once; use it to validate, generate test data, check function signatures, and produce typespecs.
Contents
- Installation
- Quick Start
- Primitives
- Named Constraints
- Combinators
- Schemas
- Registry
- Coercion
- Generators
- Function Signatures
- Typespec Bridge
- Testing
- Compared to Alternatives
- AI Agent Reference
Installation
# mix.exs
def deps do
[
{:gladius, "~> 0.1"}
]
endgladius runs a registry under its own supervision tree — no configuration needed; it starts automatically with your application.
Quick Start
import Gladius
user = schema(%{
required(:name) => string(:filled?),
required(:email) => string(:filled?, format: ~r/@/),
required(:age) => integer(gte?: 18),
optional(:role) => atom(in?: [:admin, :user, :guest])
})
Gladius.conform(user, %{name: "Mark", email: "mark@x.com", age: 33})
#=> {:ok, %{name: "Mark", email: "mark@x.com", age: 33}}
Gladius.conform(user, %{name: "", age: 15})
#=> {:error, [
#=> %Gladius.Error{path: [:name], message: "must be filled"},
#=> %Gladius.Error{path: [:email], message: "key :email must be present"},
#=> %Gladius.Error{path: [:age], message: "must be >= 18"}
#=> ]}Three entry points:
| Function | Returns |
|---|---|
Gladius.conform(spec, value) | {:ok, shaped_value} or {:error, [Error.t()]} |
Gladius.valid?(spec, value) | boolean() |
Gladius.explain(spec, value) | ExplainResult.t() with a formatted string |
result = Gladius.explain(user, %{name: "", age: 15})
result.valid? #=> false
IO.puts result.formatted
# :name: must be filled
# :email: key :email must be present
# :age: must be >= 18Primitives
import Gladius
string() # any binary
integer() # any integer
float() # any float
number() # integer or float
boolean() # true or false
atom() # any atom
map() # any map
list() # any list
any() # any value — always conforms
nil_spec() # nil onlyNamed Constraints
Named constraints are introspectable (the generator can read them) and composable.
# String constraints
string(:filled?) # non-empty
string(min_length: 3) # byte length >= 3
string(max_length: 50) # byte length <= 50
string(size?: 5) # byte length == 5
string(format: ~r/^\d{4}$/) # regex match
string(:filled?, format: ~r/@/) # shorthand atom + keyword list
# Integer constraints
integer(gt?: 0) # > 0
integer(gte?: 0) # >= 0 (→ non_neg_integer() in typespec)
integer(gt?: 0, lte?: 100) # 1 to 100
integer(gte?: 1, lte?: 100) # → 1..100 in typespec
integer(in?: [1, 2, 3]) # membership
# Float constraints
float(gt?: 0.0)
float(gte?: 0.0, lte?: 1.0)
# Atom constraints
atom(in?: [:admin, :user, :guest]) # → :admin | :user | :guest in typespecCombinators
all_of/1 — intersection
All specs must conform. The output of each is the input to the next — enabling lightweight transformation pipelines.
all_of([integer(), spec(&(&1 > 0))]) # positive integer
all_of([string(), string(:filled?)]) # non-empty string
all_of([
coerce(integer(), from: :string), # coerce string → integer
spec(&(rem(&1, 2) == 0)) # then check even
])any_of/1 — union
Tries specs in order, returns the first success.
any_of([integer(), string()]) # accepts integer or string
any_of([nil_spec(), integer()]) # nullable integer (prefer maybe/1)not_spec/1 — complement
all_of([string(), not_spec(string(:filled?))]) # empty string onlymaybe/1 — nullable
nil passes unconditionally. Non-nil values are validated against the inner spec.
maybe(string(:filled?)) # nil or non-empty string
maybe(integer(gte?: 0)) # nil or non-negative integer
maybe(ref(:address)) # nil or a valid address schemalist_of/1 — typed list
Validates every element. Errors accumulate across all elements — no short-circuiting.
list_of(integer(gte?: 0))
# [1, 2, 3] → {:ok, [1, 2, 3]}
# [1, -1, 3] → {:error, [%Error{path: [1], message: "must be >= 0"}]}
# [1, -1, -2] → {:error, [errors at index 1 and index 2]}cond_spec/2-3 — conditional branching
Applies one branch based on a predicate. Unlike any_of, makes a decision then conforms exactly one branch.
# Physical orders need a shipping address; digital orders don't
cond_spec(
fn order -> order.type == :physical end,
ref(:address_schema),
nil_spec()
)
# else_spec defaults to any() if omitted
cond_spec(&is_binary/1, string(:filled?))spec/1 — arbitrary predicate
For cases named constraints can't express. Opaque to the generator — supply :gen explicitly if needed.
spec(&is_integer/1) # guard function
spec(&(&1 > 0)) # capture
spec(fn n -> rem(n, 2) == 0 end) # anonymous function
spec(is_integer() and &(&1 > 0)) # guard + capture shorthand
spec(&is_integer/1, gen: StreamData.integer(1..1000)) # with explicit generatorcoerce/2 — coercion wrapper
See Coercion for the full reference. Coercions are combinators — they compose freely.
coerce(integer(gte?: 0), from: :string) # parse then validate
maybe(coerce(integer(), from: :string)) # nil passes; string coerces
list_of(coerce(integer(), from: :string)) # coerce every elementSchemas
schema/1 — closed map
Extra keys not declared in the schema are rejected. Errors accumulate across all keys in one pass.
user_schema = schema(%{
required(:name) => string(:filled?),
required(:email) => string(:filled?, format: ~r/@/),
required(:age) => integer(gte?: 0),
optional(:role) => atom(in?: [:admin, :user]),
optional(:address) => schema(%{
required(:street) => string(:filled?),
required(:zip) => string(size?: 5)
})
})
Gladius.conform(user_schema, %{
name: "Mark",
email: "mark@x.com",
age: 33,
address: %{street: "1 Main St", zip: "22701"}
})
#=> {:ok, %{name: "Mark", email: "mark@x.com", age: 33,
#=> address: %{street: "1 Main St", zip: "22701"}}}open_schema/1 — extra keys pass through
base = open_schema(%{required(:id) => integer(gt?: 0)})
Gladius.conform(base, %{id: 1, extra: "anything"})
#=> {:ok, %{id: 1, extra: "anything"}}ref/1 — lazy registry reference
Resolved at conform-time, not build-time. Enables circular schemas.
defspec :tree_node, schema(%{
required(:value) => integer(),
optional(:children) => list_of(ref(:tree_node)) # circular — works fine
})
Gladius.conform(ref(:tree_node), %{
value: 1,
children: [
%{value: 2, children: []},
%{value: 3}
]
})
#=> {:ok, %{value: 1, children: [%{value: 2, children: []}, %{value: 3}]}}Registry
defspec — globally named spec
defmodule MyApp.Specs do
import Gladius
defspec :email, string(:filled?, format: ~r/@/)
defspec :username, string(:filled?, min_length: 3, max_length: 32)
defspec :age, integer(gte?: 0, lte?: 150)
defspec :role, atom(in?: [:admin, :user, :guest])
end
Reference with ref/1 from anywhere in the codebase:
schema(%{
required(:email) => ref(:email),
required(:username) => ref(:username),
required(:age) => ref(:age),
optional(:role) => ref(:role)
})defschema — named validator functions
Generates name/1 → {:ok, shaped} | {:error, errors} and name!/1 → shaped value or raises ConformError.
defmodule MyApp.Schemas do
import Gladius
defschema :user do
schema(%{
required(:name) => string(:filled?),
required(:email) => ref(:email),
required(:age) => integer(gte?: 18),
optional(:role) => atom(in?: [:admin, :user])
})
end
defschema :create_params do
schema(%{
required(:email) => coerce(ref(:email), from: :string),
required(:age) => coerce(integer(gte?: 18), from: :string),
optional(:username) => coerce(ref(:username), from: :string)
})
end
end
MyApp.Schemas.user(%{name: "Mark", email: "m@x.com", age: 33})
#=> {:ok, %{name: "Mark", email: "m@x.com", age: 33}}
MyApp.Schemas.user!(%{name: "", age: 15})
#=> raises Gladius.ConformErrorCoercion
coerce/2 wraps a spec with a pre-processing step.
Pipeline:raw value → coerce → type check → constraints → {:ok, coerced value}
Coercion failure produces %Error{predicate: :coerce} and skips downstream checks.
Custom function
coerce(integer(), fn
v when is_binary(v) ->
case Integer.parse(String.trim(v)) do
{n, ""} -> {:ok, n}
_ -> {:error, "not a valid integer string: #{inspect(v)}"}
end
v when is_integer(v) -> {:ok, v}
v -> {:error, "cannot coerce #{inspect(v)} to integer"}
end)
Built-in shorthand — from: source_type
All built-in coercions are idempotent — already-correct values pass through unchanged.
coerce(integer(), from: :string) # "42" → 42 (trims whitespace)
coerce(float(), from: :string) # "3.14" → 3.14 (integers pass as floats)
coerce(boolean(), from: :string) # "true" → true (yes/1/on also work)
coerce(atom(), from: :string) # "ok" → :ok (existing atoms only — safe)
coerce(float(), from: :integer) # 42 → 42.0
coerce(string(), from: :integer) # 42 → "42"
coerce(boolean(), from: :integer) # 0 → false, 1 → true (others fail)
coerce(string(), from: :atom) # :ok → "ok"
coerce(integer(), from: :float) # 3.7 → 3 (truncates toward zero)
coerce(string(), from: :float) # 3.14 → "3.14"User-extensible coercion registry
Register at application startup. User coercions take precedence over built-ins for the same {source, target} pair.
# In Application.start/2 or a @on_load:
Gladius.Coercions.register({:decimal, :float}, fn
%Decimal{} = d -> {:ok, Decimal.to_float(d)}
v when is_float(v) -> {:ok, v}
v -> {:error, "cannot coerce #{inspect(v)} to float"}
end)
# Then anywhere in your app:
coerce(float(gt?: 0.0), from: :decimal):persistent_term backs the registry — reads are free, writes trigger a GC pass. Register once at startup, not in hot paths.
Composition patterns
# Nullable coercion
maybe(coerce(integer(gte?: 0), from: :string))
# nil → {:ok, nil} | "42" → {:ok, 42} | "-5" → {:error, gte? failure}
# HTTP params / form data schema
http_params = schema(%{
required(:age) => coerce(integer(gte?: 18), from: :string),
required(:active) => coerce(boolean(), from: :string),
required(:score) => coerce(float(gt?: 0.0), from: :string),
optional(:role) => coerce(atom(in?: [:admin, :user]), from: :string)
})
Gladius.conform(http_params, %{age: "25", active: "true", score: "9.5", role: "admin"})
#=> {:ok, %{age: 25, active: true, score: 9.5, role: :admin}}
# Coercion in list_of — every element is coerced
list_of(coerce(integer(), from: :string))
# ["1", "2", "3"] → {:ok, [1, 2, 3]}Generators
gen/1 infers a StreamData generator from any spec. Available in :dev and :test — zero overhead in :prod.
import Gladius
gen(string(:filled?)) # non-empty strings
gen(integer(gte?: 0, lte?: 100)) # integers 0–100
gen(atom(in?: [:admin, :user])) # :admin or :user
gen(maybe(integer())) # nil | integer
gen(list_of(string(:filled?))) # list of non-empty strings
gen(any_of([integer(), string()])) # integer | string
gen(schema(%{
required(:name) => string(:filled?),
required(:age) => integer(gte?: 0)
})) # map matching the schema
Use with ExUnitProperties:
defmodule MyApp.SpecTest do
use ExUnitProperties
import Gladius
property "conform is idempotent for valid values" do
spec = schema(%{
required(:email) => string(:filled?, format: ~r/@/),
required(:age) => integer(gte?: 0, lte?: 150)
})
check all value <- gen(spec) do
{:ok, shaped} = Gladius.conform(spec, value)
assert Gladius.conform(spec, shaped) == {:ok, shaped}
end
end
end
Custom generator for opaque specs — supply :gen explicitly:
even = spec(&(rem(&1, 2) == 0), gen: StreamData.map(StreamData.integer(), &(&1 * 2)))
gen(even) # generates even integersFunction Signatures
use Gladius.Signature enables runtime validation in :dev and :test. Zero overhead in :prod — the macro compiles the wrappers away entirely.
Basic usage
defmodule MyApp.Users do
use Gladius.Signature
signature args: [string(:filled?), integer(gte?: 18)],
ret: boolean()
def register(email, age) do
# impl — receives validated arguments
true
end
end
MyApp.Users.register("mark@x.com", 33) #=> true
MyApp.Users.register("", 33) #=> raises SignatureError
MyApp.Users.register("mark@x.com", 15) #=> raises SignatureErrorOptions
| Key | Validates |
|---|---|
:args | List of specs, one per argument, positional |
:ret | Return value |
:fn | {coerced_args_list, return_value} — input/output relationships |
# :fn — return must be >= first argument
signature args: [integer(), integer()],
ret: integer(),
fn: spec(fn {[a, _b], ret} -> ret >= a end)
def add(a, b), do: a + bMulti-clause functions
Declare signature once, before the first clause only.
signature args: [integer()], ret: integer()
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)Coercion threading
When :args specs include coercions, the coerced values are forwarded to the impl — not the originals.
signature args: [coerce(integer(gte?: 0), from: :string)],
ret: string()
def double(n), do: Integer.to_string(n * 2)
MyApp.double("5") #=> "10" — impl receives integer 5, not string "5"
MyApp.double(5) #=> "10" — already integer, passes through
MyApp.double("bad") #=> raises SignatureErrorPath errors
All failing arguments are collected in one raise. Nested schema field failures include the full path down to the failing field.
signature args: [schema(%{
required(:email) => string(:filled?, format: ~r/@/),
required(:name) => string(:filled?)
})],
ret: boolean()
def create(params), do: true
MyApp.create(%{email: "bad", name: ""})
# raises Gladius.SignatureError:
# MyApp.create/1 argument error:
# argument[0][:email]: format must match ~r/@/
# argument[0][:name]: must be filled
# SignatureError.errors contains:
# [
# %Gladius.Error{path: [{:arg, 0}, :email], message: "format must match ~r/@/"},
# %Gladius.Error{path: [{:arg, 0}, :name], message: "must be filled"}
# ]Typespec Bridge
Converts gladius specs to quoted Elixir typespec AST. Bridges runtime validation and the compile-time type system — specs become the single source of truth for both.
to_typespec/1
import Gladius
alias Macro
Macro.to_string(Gladius.to_typespec(integer(gte?: 0))) #=> "non_neg_integer()"
Macro.to_string(Gladius.to_typespec(integer(gt?: 0))) #=> "pos_integer()"
Macro.to_string(Gladius.to_typespec(integer(gte?: 1, lte?: 100))) #=> "1..100"
Macro.to_string(Gladius.to_typespec(atom(in?: [:a, :b]))) #=> ":a | :b"
Macro.to_string(Gladius.to_typespec(maybe(string()))) #=> "String.t() | nil"
Macro.to_string(Gladius.to_typespec(list_of(integer()))) #=> "[integer()]"
Macro.to_string(Gladius.to_typespec(ref(:email))) #=> "email()"
Macro.to_string(Gladius.to_typespec(any_of([string(), integer()]))) #=> "String.t() | integer()"
Macro.to_string(Gladius.to_typespec(schema(%{
required(:name) => string(),
optional(:age) => integer(gte?: 0)
})))
#=> "%{required(:name) => String.t(), optional(:age) => non_neg_integer()}"Fidelity table
| Gladius spec | Elixir typespec | Fidelity |
|---|---|---|
string() | String.t() | exact |
integer(gte?: 0) | non_neg_integer() | exact |
integer(gt?: 0) | pos_integer() | exact |
integer(gte?: a, lte?: b) | a..b | exact |
integer(in?: [1, 2, 3]) | 1 | 2 | 3 | exact |
atom(in?: [:a, :b]) | :a | :b | exact |
float() / number() / boolean() / atom() | same | exact |
nil_spec() | nil | exact |
maybe(s) | T | nil | exact |
list_of(s) | [T] | exact |
any_of([s1, s2]) | T1 | T2 | exact |
ref(:name) | name() | exact |
schema(%{...}) / open_schema | %{required(:k) => T, ...} | exact |
string(:filled?) | String.t() | lossy — constraint elided |
all_of([s1, s2]) | first typed spec's type | lossy — intersection unsupported |
cond_spec(f, s1, s2) | T1 | T2 | lossy — predicate elided |
not_spec(s) | term() | inexpressible |
coerce(s, ...) | target type only | lossy — input type omitted |
typespec_lossiness/1
Gladius.typespec_lossiness(string(:filled?))
#=> [{:constraint_not_expressible, "filled?: true has no typespec equivalent"}]
Gladius.typespec_lossiness(not_spec(integer()))
#=> [{:negation_not_expressible, "not_spec has no typespec equivalent; term() used"}]
Gladius.typespec_lossiness(integer(gte?: 0, lte?: 100))
#=> [] # lossless@type generation
defspec and defschema accept type: true to auto-generate a @type declaration. Lossy constraints emit compile-time warnings pointing to the call site.
import Gladius
defspec :user_id, integer(gte?: 1), type: true
# @type user_id :: pos_integer()
defspec :email, string(:filled?, format: ~r/@/), type: true
# @type email :: String.t()
# warning: defspec :email type: format: ~r"/@/" has no typespec equivalent
defschema :profile, type: true do
schema(%{
required(:name) => string(:filled?),
required(:age) => integer(gte?: 0),
optional(:role) => atom(in?: [:admin, :user])
})
end
# @type profile :: %{required(:name) => String.t(),
# required(:age) => non_neg_integer(),
# optional(:role) => :admin | :user}
For macro injection, Gladius.Typespec.type_ast/2 returns the @type declaration AST directly:
ast = Gladius.Typespec.type_ast(:my_type, integer(gte?: 0))
# Inject into a module at compile time:
Module.eval_quoted(MyModule, ast)Testing
Process-local registry for async tests
Never use Gladius.Registry.clear/0 in async tests — it clears the global ETS table. Use the process-local overlay instead.
defmodule MyApp.SpecTest do
use ExUnit.Case, async: true
import Gladius
setup do
on_exit(&Gladius.Registry.clear_local/0)
:ok
end
test "ref resolves to a locally registered spec" do
Gladius.Registry.register_local(:test_email, string(:filled?, format: ~r/@/))
spec = schema(%{required(:email) => ref(:test_email)})
assert {:ok, _} = Gladius.conform(spec, %{email: "a@b.com"})
assert {:error, _} = Gladius.conform(spec, %{email: "bad"})
end
endProperty-based testing
defmodule MyApp.PropertyTest do
use ExUnitProperties
import Gladius
property "generated values always conform" do
spec = schema(%{
required(:name) => string(:filled?),
required(:age) => integer(gte?: 0, lte?: 150),
optional(:score) => float(gte?: 0.0, lte?: 1.0)
})
check all value <- gen(spec) do
assert {:ok, _} = Gladius.conform(spec, value)
end
end
property "conform is idempotent for valid values" do
spec = string(:filled?)
check all value <- gen(spec) do
{:ok, shaped} = Gladius.conform(spec, value)
assert Gladius.conform(spec, shaped) == {:ok, shaped}
end
end
endCompared to Alternatives
| gladius | Norm | Drops | Peri | |
|---|---|---|---|---|
| Parse, don't validate | ✓ | ✓ | ✓ | ✓ |
| Named constraints | ✓ | — | ✓ | ✓ |
| Generator inference | ✓ | — | — | — |
| Function signatures | ✓ | ✓ | — | — |
| Coercion pipeline | ✓ | — | ✓ | — |
| User coercion registry | ✓ | — | — | — |
| Typespec bridge | ✓ | — | — | — |
@type generation | ✓ | — | — | — |
Circular schemas (ref) | ✓ | — | — | — |
| Prod zero-overhead signatures | ✓ | — | ✓ | ✓ |
| Accumulating schema errors | ✓ | ✓ | ✓ | ✓ |
AI Agent Reference
This section is structured for machine consumption. Complete API surface, all constraint names, error formats, and behavioural guarantees.
Module map
| Module | Purpose |
|---|---|
Gladius |
Primary API — import Gladius |
Gladius.Signature |
Function signature validation — use Gladius.Signature in module |
Gladius.Typespec | Spec → typespec AST conversion |
Gladius.Coercions | Coercion functions + user registry |
Gladius.Registry | Named spec registry (ETS + process-local) |
Gladius.Gen | Generator inference (dev/test only) |
Gladius.Error | Validation failure struct |
Gladius.SignatureError | Raised on signature violation |
Gladius.ConformError |
Raised by defschema name!/1 |
Complete Gladius function signatures
# Primitive builders (all accept keyword constraints)
string() | string(atom) | string(atom, kw) | string(kw)
integer() | integer(atom) | integer(atom, kw) | integer(kw)
float() | float(atom) | float(atom, kw) | float(kw)
number() | boolean() | map() | list() | any() | nil_spec()
atom() | atom(kw)
# Combinators
all_of([conformable()]) :: All.t()
any_of([conformable()]) :: Any.t()
not_spec(conformable()) :: Not.t()
maybe(conformable()) :: Maybe.t()
list_of(conformable()) :: ListOf.t()
cond_spec(pred_fn, if_spec) :: Cond.t()
cond_spec(pred_fn, if_spec, else_spec) :: Cond.t()
coerce(Spec.t(), (term -> {:ok, t} | {:error, s})) :: Spec.t()
coerce(Spec.t(), from: source_atom) :: Spec.t()
ref(atom) :: Ref.t()
spec(pred_or_guard_expr) :: Spec.t()
spec(pred_or_guard_expr, gen: StreamData.t()) :: Spec.t()
# Schema
schema(%{schema_key => conformable()}) :: Schema.t()
open_schema(%{schema_key => conformable()}) :: Schema.t()
required(atom) # → SchemaKey used as map key in schema/1
optional(atom) # → SchemaKey used as map key in schema/1
# Registration (macros — expand at compile time)
defspec name_atom, spec_expr
defspec name_atom, spec_expr, type: true
defschema name_atom do spec_expr end
defschema name_atom, type: true do spec_expr end
# Validation
Gladius.conform(conformable(), term()) :: {:ok, term()} | {:error, [Error.t()]}
Gladius.valid?(conformable(), term()) :: boolean()
Gladius.explain(conformable(), term()) :: ExplainResult.t()
# Generator (dev/test only — raises in prod)
Gladius.gen(conformable()) :: StreamData.t()
# Typespec
Gladius.to_typespec(conformable()) :: Macro.t()
Gladius.typespec_lossiness(conformable()) :: [{atom(), String.t()}]
Gladius.Typespec.type_ast(atom, conformable()) :: Macro.t()All named constraints by type
# String
:filled? non-empty — byte_size > 0
min_length: n byte_size >= n
max_length: n byte_size <= n
size?: n byte_size == n
format: ~r/regex/ must match regex
# Integer / Float / Number
gt?: n > n
gte?: n >= n
lt?: n < n
lte?: n <= n
in?: [values] member of list (integer or atom)
# Atom
in?: [atoms] member of atom listAll built-in coercion pairs
{:string, :integer} Integer.parse, trims whitespace, strict (no trailing chars)
{:string, :float} Float.parse; integers pass through as floats
{:string, :boolean} true/yes/1/on → true; false/no/0/off → false (case-insensitive)
{:string, :atom} String.to_existing_atom — safe against atom table exhaustion
{:string, :number} same as {:string, :float}
{:integer, :float} n * 1.0
{:integer, :string} Integer.to_string
{:integer, :boolean} 0 → false, 1 → true; any other integer → error
{:atom, :string} Atom.to_string; nil → error (nil is an atom in Elixir)
{:float, :integer} trunc/1 — truncates toward zero; 3.7 → 3, -3.7 → -3
{:float, :string} "#{v}"All coercions are idempotent: values already of the target type pass through unchanged.
Gladius.Error struct
%Gladius.Error{
path: [atom() | non_neg_integer()],
# [] = root-level failure
# [:email] = top-level key failure
# [:address, :zip] = nested key failure
# [:items, 2, :name] = list element nested failure
predicate: atom() | nil,
# named constraints: :filled?, :gte?, :gt?, :lte?, :lt?, :format, :in?,
# :min_length, :max_length, :size?, :coerce
# arbitrary spec: nil
value: term(), # the value that failed (after coercion if any)
message: String.t(),
meta: map()
}
# String.Chars impl:
to_string(%Error{path: [], message: "must be a map"})
#=> "must be a map"
to_string(%Error{path: [:address, :zip], message: "must be 5 characters"})
#=> ":address.:zip: must be 5 characters"
to_string(%Error{path: [:items, 2, :name], message: "must be filled"})
#=> ":items.[2].:name: must be filled"Gladius.SignatureError struct
%Gladius.SignatureError{
module: module(),
function: atom(),
arity: non_neg_integer(),
kind: :args | :ret | :fn,
errors: [Gladius.Error.t()]
}
# Error path prefixes injected by Gladius.Signature:
# {:arg, 0} → "argument[0]" for args errors
# :ret → "return" for ret errors
# :fn → "fn" for fn errors
# Example paths in errors:
[{:arg, 0}] # root-level arg failure (wrong type, etc.)
[{:arg, 0}, :email] # arg 0 is a schema; :email field failed
[{:arg, 0}, :items, 2] # arg 0 is a schema; items[2] failed
[:ret] # return value root-level failure
[:ret, :name] # return is a schema; :name field failed
# Exception.message/1 format:
"MyApp.Users.register/2 argument error:\n argument[0][:email]: must be filled\n argument[1]: must be >= 18"Gladius.Registry API
# Global ETS-backed (survives process restarts)
Gladius.Registry.register(name :: atom, spec :: conformable()) :: :ok
Gladius.Registry.unregister(name :: atom) :: :ok
Gladius.Registry.fetch!(name :: atom) :: conformable() # raises if missing
Gladius.Registry.registered?(name :: atom) :: boolean()
Gladius.Registry.all() :: %{atom => conformable()}
Gladius.Registry.clear() :: :ok # DANGER: global, avoid in async tests
# Process-local overlay (for async-safe test isolation)
Gladius.Registry.register_local(name :: atom, spec :: conformable()) :: :ok
Gladius.Registry.unregister_local(name :: atom) :: :ok
Gladius.Registry.clear_local() :: :ok # safe in on_exitfetch!/1 checks the process-local overlay first, then the global ETS table.
Gladius.Coercions API
Gladius.Coercions.register({source :: atom, target :: atom}, fun :: (term -> {:ok, t} | {:error, s})) :: :ok
Gladius.Coercions.registered() :: %{{atom, atom} => function()}
Gladius.Coercions.lookup(source :: atom, target :: atom) :: function()
# Raises ArgumentError if no coercion exists (programming error, not data error)Typespec lossiness reasons
:constraint_not_expressible string constraints: filled?, format:, min_length:, max_length:, size?
:intersection_not_expressible all_of: first typed spec used, rest ignored
:negation_not_expressible not_spec: falls back to term()
:predicate_not_expressible cond_spec: predicate fn lost; union of branches used
:coercion_not_expressible coerce: only target type appears; input type not represented
Type union — the conformable() type
conformable() =
Gladius.Spec # primitives and coerce wrappers
| Gladius.All # all_of
| Gladius.Any # any_of
| Gladius.Not # not_spec
| Gladius.Maybe # maybe
| Gladius.Ref # ref
| Gladius.ListOf # list_of
| Gladius.Cond # cond_spec
| Gladius.Schema # schema / open_schemaBehavioural guarantees
conform/2is the single entry point.valid?/2calls it and discards the value.explain/2calls it and formats the errors.Specs are plain structs. Store in module attributes, pass to functions, compose freely. No hidden state, no registration required unless you use
ref/1.all_of/1pipelines. Each spec's shaped output is the next spec's input. Coercions in position 0 transform the value for subsequent specs.ref/1is lazy. Resolved atconform/2call time. Use for forward references and circular schemas. Must be registered beforeconform/2is called (not before the spec is built).Schema errors accumulate.
schema/1,open_schema/1,list_of/1, and__coerce_and_check_args__never short-circuit — all failures are returned at once.defspec/defschemawithtype: truerequires evaluable spec expressions.Code.eval_quotedruns at macro expansion time with the caller'sMacro.Env. Spec expressions must be evaluable with the caller's imports. Variables from the surrounding runtime scope are not available.signatureis prod-safe.Mix.env()is checked at macro expansion time. In:prod,signature/1is a no-op anddefdelegates directly toKernel.def. Never guard signature calls withMix.env()yourself.Coercion registry is global and permanent.
Gladius.Coercions.register/2uses:persistent_term. There is nounregister. Call once at startup; never in tests or hot paths.Process-local registry for test isolation. Use
register_local/2+clear_local/0inon_exit.clear/0clears the global ETS table — safe only in synchronous (non-async) test setup.gen/1raises in:prod. Keep generator calls in:dev/:testcode. For conditional use, guard withif Mix.env() != :prod.