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.

Hex.pmHex Docs


Contents


Installation

# mix.exs
def deps do
  [
    {:gladius, "~> 0.1"}
  ]
end

gladius 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 >= 18

Primitives

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 only

Named 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 typespec

Combinators

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 only

maybe/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 schema

list_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&#39;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 generator

coerce/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 element

Schemas

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.ConformError

Coercion

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 integers

Function 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 SignatureError

Options

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 + b

Multi-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 SignatureError

Path 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
end

Property-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
end

Compared 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 list

All 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_exit

fetch!/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_schema

Behavioural guarantees

  1. conform/2 is the single entry point.valid?/2 calls it and discards the value. explain/2 calls it and formats the errors.

  2. Specs are plain structs. Store in module attributes, pass to functions, compose freely. No hidden state, no registration required unless you use ref/1.

  3. all_of/1 pipelines. Each spec's shaped output is the next spec's input. Coercions in position 0 transform the value for subsequent specs.

  4. ref/1 is lazy. Resolved at conform/2 call time. Use for forward references and circular schemas. Must be registered before conform/2 is called (not before the spec is built).

  5. 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.

  6. defspec/defschema with type: true requires evaluable spec expressions.Code.eval_quoted runs at macro expansion time with the caller's Macro.Env. Spec expressions must be evaluable with the caller's imports. Variables from the surrounding runtime scope are not available.

  7. signature is prod-safe.Mix.env() is checked at macro expansion time. In :prod, signature/1 is a no-op and def delegates directly to Kernel.def. Never guard signature calls with Mix.env() yourself.

  8. Coercion registry is global and permanent.Gladius.Coercions.register/2 uses :persistent_term. There is no unregister. Call once at startup; never in tests or hot paths.

  9. Process-local registry for test isolation. Use register_local/2 + clear_local/0 in on_exit. clear/0 clears the global ETS table — safe only in synchronous (non-async) test setup.

  10. gen/1 raises in :prod. Keep generator calls in :dev/:test code. For conditional use, guard with if Mix.env() != :prod.