Changelog
All notable changes to Gladius are documented here.
The format follows Keep a Changelog. Gladius adheres to Semantic Versioning.
0.4.0 — Unreleased
Added
Schema extension — extend/2 and extend/3
extend/2 builds a new %Schema{} from an existing one by merging in additional
or overriding keys. No structs were added — the output is a plain %Schema{}.
base = schema(%{
required(:name) => string(:filled?),
required(:email) => string(:filled?, format: ~r/@/),
required(:age) => integer(gte?: 0)
})
create = extend(base, %{required(:password) => string(min_length: 8)})
update = extend(base, %{optional(:role) => atom(in?: [:admin, :user])})
patch = selection(update, [:name, :email, :age, :role])Semantics:
-
Extension keys that override a base key replace the spec and
required?flag in-place — the key stays at its original position in the schema - New extension keys are appended after all base keys
open?is inherited from the base schema; override withextend/3open:opt-
Does not mutate the base — always returns a new
%Schema{} extend/2can be chained — the result ofextendcan be extended again- All coercions, transforms, defaults, and custom messages on the original spec are replaced when a key is overridden (the extension key's spec is used as-is)
Ecto nested embed support
Gladius.Ecto.changeset/2-3 now builds proper nested %Ecto.Changeset{} values
for fields whose spec is (or wraps) a %Gladius.Schema{} or list_of(schema(...)),
and registers those fields with Ecto embedded types in cs.types. This makes
nested changeset fields compatible with Phoenix inputs_for/4 in LiveView.
Type declarations injected after cast:
%Schema{}field →{:parameterized, Ecto.Embedded, %Ecto.Embedded{cardinality: :one, field: name}}list_of(schema)field →{:parameterized, Ecto.Embedded, %Ecto.Embedded{cardinality: :many, field: name}}
Embed types are injected afterEcto.Changeset.cast/4 (which uses :map for
safety) to avoid Ecto.Type.cast_fun/1 raising on unknown parameterized types.
List of embedded schemas:
schema(%{
required(:name) => string(:filled?),
required(:tags) => list_of(schema(%{required(:name) => string(:filled?)}))
})
cs = Gladius.Ecto.changeset(s, params)
cs.changes.tags #=> [%Ecto.Changeset{}, ...]
cs.types[:tags] #=> {:parameterized, Ecto.Embedded, %Ecto.Embedded{cardinality: :many}}Gladius.Ecto.traverse_errors/2
New public function that recursively collects errors from Gladius-built nested
changesets. Use this instead of Ecto.Changeset.traverse_errors/2 — Ecto's
built-in only recurses into fields whose type is a declared embed or association,
and does not find errors in Gladius nested changesets.
Gladius.Ecto.traverse_errors(cs, fn {msg, _} -> msg end)
#=> %{name: ["can't be blank"], address: %{zip: ["must be exactly 5 characters"]}}
# tags: [%{}, %{name: ["must be filled"]}] # list form for many embedsFixed
Gladius.Ecto— string-keyed params inside nested maps and lists now atomize recursively. Previously%{"address" => %{"zip" => "bad"}}and[%{"name" => "x"}]kept string keys in nested positions, causing Gladius schemas (which use atom keys) to report all nested required fields as missing.
Changed
Gladius.Ecto.changeset/2-3— nested schema fields now produce%Ecto.Changeset{}values inchangesinstead of plain maps, and embed type entries intypes. Existing code that accessedcs.changes.nested_fieldas a plain map will need to access it as a changeset:cs.changes.nested_field.changes.
0.3.0 — Unreleased
Added
Custom error messages — message: option
Every spec builder and combinator now accepts a message: option that overrides
the generated error string for any failure. Accepts two forms:
- String — returned as-is, bypasses any configured translator.
- Tuple
{domain, msgid, bindings}— dispatched through a translator if configured; falls back tomsgidwhen no translator is set.
string(:filled?, message: "can't be blank")
integer(gte?: 18, message: {"errors", "must be at least %{min}", [min: 18]})
coerce(integer(), from: :string, message: "must be a valid number")
transform(string(), &String.trim/1, message: "normalization failed")
maybe(string(:filled?), message: "must be a non-empty string or nil")
Supported on: all primitive builders (string, integer, float, number,
boolean, atom, map, list, any), coerce/2, transform/2, maybe/1,
default/2, all_of/1, any_of/1, not_spec/1, schema/1, open_schema/1,
and the spec/1 macro.
i18n translator hook — Gladius.Translator
New Gladius.Translator behaviour for plugging in a custom message translator.
Configure via application env:
config :gladius, translator: MyApp.GladiusTranslator
When configured, all built-in error messages pass through
translator.translate(domain, msgid, bindings). Plain string message: overrides
bypass the translator. Designed to be compatible with Gettext, LLM-based
translation, or any custom backend.
Structured error metadata — message_key and message_bindings
%Gladius.Error{} gains two new fields populated by every built-in error:
message_key :: atom() | nil— the predicate that failed (:gte?,:filled?,:type?,:coerce,:transform,:validate, etc.)message_bindings :: keyword()— dynamic values used in the message (e.g.[min: 18]for agte?failure,[format: ~r/@/]for a format failure)
These fields are always populated regardless of whether message: is set,
allowing translators and custom renderers to work from structured data.
Partial schemas — selection/2
selection/2 takes an existing %Gladius.Schema{} and a list of field names,
returning a new schema with only those fields — all made optional. The primary
use case is PATCH endpoints.
patch = selection(user_schema, [:name, :email, :age, :role])
Gladius.conform(patch, %{}) #=> {:ok, %{}}
Gladius.conform(patch, %{name: "Mark"}) #=> {:ok, %{name: "Mark"}}
Gladius.conform(patch, %{age: -1}) #=> {:error, [...]}- Selected fields absent → omitted from output, no error
- Selected fields present → validated by their original spec; all coercions, transforms, defaults, and custom messages apply
- Non-selected fields in input → rejected by closed schemas (prevents mass-assignment)
open?is inherited from the source schema
Cross-field validation — validate/2
validate/2 attaches validation rules that run only after the inner spec fully
passes. Multiple calls chain by appending rules to the same %Gladius.Validate{}
struct — they do not nest.
schema(%{
required(:start_date) => string(:filled?),
required(:end_date) => string(:filled?)
})
|> validate(fn %{start_date: s, end_date: e} ->
if e >= s, do: :ok, else: {:error, :end_date, "must be on or after start date"}
end)
|> validate(&check_business_hours/1)
Rule return values: :ok, {:error, field, message}, {:error, :base, message},
{:error, [{field, message}]}. Exceptions are caught and returned as
%Error{predicate: :validate}. All rules run; errors accumulate.
Ecto nested changeset support + Gladius.Ecto.traverse_errors/2
Gladius.Ecto.changeset/2-3 now builds proper nested %Ecto.Changeset{}
structs for fields whose spec is (or wraps) a %Gladius.Schema{}. Nested
errors appear in the nested changeset rather than the parent's errors list,
making them compatible with Phoenix inputs_for and deep error inspection.
New Gladius.Ecto.traverse_errors/2 recursively collects errors from nested
changesets. Use it instead of Ecto.Changeset.traverse_errors/2 — Ecto's
built-in only recurses into declared embed/assoc typed fields.
cs = Gladius.Ecto.changeset(user_schema, params)
Gladius.Ecto.traverse_errors(cs, fn {msg, _} -> msg end)
#=> %{name: ["can't be blank"], address: %{zip: ["must be exactly 5 characters"]}}
Also fixed: string-keyed nested params are now deep-atomized before conforming,
so Phoenix-style %{"address" => %{"zip" => "bad"}} params work correctly.
Changed
%Gladius.Error{}— two new fields:message_key :: atom() | nilandmessage_bindings :: keyword(). Existing fields unchanged; new fields default toniland[]respectively, so existing pattern matches continue to work.%Gladius.Spec{},%Gladius.All{},%Gladius.Any{},%Gladius.Not{},%Gladius.Maybe{},%Gladius.Schema{},%Gladius.Default{},%Gladius.Transform{}— newmessagefield (defaults tonil). Existing struct literals withoutmessage:continue to work.conformable()type union extended withGladius.Validate.Gladius.Gen.gen/1andGladius.Typespec.to_typespec/1handle%Validate{}by delegating to the inner spec.Gladius.Ecto.changeset/2-3— nested schema fields produce nested changesets rather than plain map changes.cs.changes.nested_fieldis now an%Ecto.Changeset{}rather than a raw map for schema-typed fields.
0.2.0 — Unreleased
Added
Default values — default/2
New combinator that injects a fallback when an optional schema key is absent. The fallback is injected as-is — the inner spec only runs when the key is present.
schema(%{
required(:name) => string(:filled?),
optional(:role) => default(atom(in?: [:admin, :user]), :user),
optional(:retries) => default(integer(gte?: 0), 3)
})- Absent key → fallback injected; inner spec not run
- Present key → inner spec validates the provided value normally
- Invalid provided value → error returned; fallback does not rescue it
-
Required key →
default/2has no effect on absence -
Composes with
ref/1— a ref pointing to a%Default{}resolves correctly
Post-validation transforms — transform/2
New combinator that applies a function to the shaped value after validation
succeeds. Never runs on invalid data. Exceptions from the transform function
are caught and surfaced as %Gladius.Error{predicate: :transform}.
schema(%{
required(:name) => transform(string(:filled?), &String.trim/1),
required(:email) => transform(string(:filled?, format: ~r/@/), &String.downcase/1)
})
# Chainable via pipe — transform/2 is spec-first:
string(:filled?)
|> transform(&String.trim/1)
|> transform(&String.downcase/1)-
Runs after coercion and validation:
raw → coerce → validate → transform → {:ok, result} -
Absent optional keys with
default(transform(...), val)bypass the transform gen/1andto_typespec/1delegate to the inner spec
Struct validation
conform/2 now accepts any Elixir struct as input. The struct is converted
to a plain map via Map.from_struct/1 before dispatch. Output is a plain map.
Gladius.conform(schema, %User{name: "Mark", email: "mark@x.com"})
#=> {:ok, %{name: "Mark", email: "mark@x.com"}}conform_struct/2 validates a struct and re-wraps the shaped output in the
original struct type on success.
Gladius.conform_struct(schema, %User{name: " Mark ", age: "33"})
#=> {:ok, %User{name: "Mark", age: 33}}defschema now accepts a struct: true option that defines both the
validator functions and a matching output struct in a single declaration.
The struct module is named <CallerModule>.<PascalName>Schema.
defmodule MyApp.Schemas do
import Gladius
defschema :point, struct: true do
schema(%{required(:x) => integer(), required(:y) => integer()})
end
end
MyApp.Schemas.point(%{x: 3, y: 4})
#=> {:ok, %MyApp.Schemas.PointSchema{x: 3, y: 4}}
Ecto integration — Gladius.Ecto
New optional module Gladius.Ecto (guarded by
Code.ensure_loaded?(Ecto.Changeset)) that converts a Gladius schema into an
Ecto.Changeset. Requires {:ecto, "~> 3.0"} in the consuming application's
dependencies — Gladius does not pull it in transitively.
# Schemaless (create workflows)
Gladius.Ecto.changeset(gladius_schema, params)
# Schema-aware (update workflows)
Gladius.Ecto.changeset(gladius_schema, params, %User{})- String-keyed params (the Phoenix default) are normalised to atom keys before conforming — no manual atomisation step needed
-
On
{:ok, shaped}— changeset is valid;changescontains the fully shaped output with coercions, transforms, and defaults applied -
On
{:error, errors}— changeset is invalid; each%Gladius.Error{}is mapped toadd_error/3keyed on the last path segment (%Error{path: [:address, :zip]}→add_error(cs, :zip, ...)) -
Returns a plain
%Ecto.Changeset{}— pipe Ecto validators after as normal
Changed
conformable()type union extended withGladius.DefaultandGladius.TransformGladius.Gen.gen/1andGladius.Typespec.to_typespec/1now handle%Default{}and%Transform{}by delegating to their inner spec
0.1.0 — unreleased
First public release.
Spec algebra
- Primitive builders —
string/0-2,integer/0-2,float/0-2,number/0,boolean/0,atom/0-1,map/0,list/0-2,any/0,nil_spec/0 - Named constraints —
filled?,gt?,gte?,lt?,lte?,min_length:,max_length:,size?:,format:,in?— introspectable and generator-aware - Arbitrary predicates —
spec/1for cases named constraints can't cover - Combinators —
all_of/1(intersection),any_of/1(union),not_spec/1(complement),maybe/1(nullable),list_of/1(typed list),cond_spec/2-3(conditional branching) - Coercion —
coerce/2wraps any spec with a pre-processing step; runs before type-checking and constraints - Schemas —
schema/1(closed) andopen_schema/1; errors accumulated across all keys in one pass, no short-circuiting
Registry
defspec/2-3— registers a named spec globally in ETS; accessible from any process viaref/1defschema/2-3— generatesname/1andname!/1validator functions in the calling moduleref/1— lazy registry reference; resolved at conform-time, enabling circular schemas-
Process-local overlay (
register_local/2) for async-safe test isolation
Coercion pipeline
- Built-in source types —
:string,:integer,:atom,:float - Built-in pairs — 11 source→target coercions: string→integer/float/ boolean/atom/number, integer→float/string/boolean, atom→string, float→integer/string
- User-extensible registry —
Gladius.Coercions.register/2backed by:persistent_term; user coercions take precedence over built-ins
Generator inference
gen/1— infers aStreamDatagenerator from any spec- Supports all primitives, combinators, and schemas
-
Bounds-over-filters strategy for constrained numeric/string specs
(avoids
FilterTooNarrowError) -
Custom generators via
spec(pred, gen: my_generator)
Function signature checking
use Gladius.Signature— opt-in per modulesignature args: [...], ret: ..., fn: ...— declares arg specs, return spec, and optional relationship constraint- Validates and coerces all args before the impl runs; coerced values are forwarded (not the originals)
-
Multi-clause functions: declare
signatureonce before the first clause - Path errors — all failing args reported in one raise; each error path
prefixed with
{:arg, N}so nested schema field failures render asargument[0][:email]: must be filled -
Zero overhead in
:prod— signatures compile away entirely
Typespec bridge
to_typespec/1— converts any Gladius spec to quoted Elixir typespec ASTtypespec_lossiness/1— reports constraints that have no typespec equivalent (string format, negation, intersection, etc.)type_ast/2— generates@type name :: typedeclaration AST for macro injectiondefspec :name, spec, type: true— auto-generates@typewith compile-time lossiness warningsdefschema :name, type: true do ... end— same for schemas-
Integer constraint specialisation:
gte?: 0→non_neg_integer(),gt?: 0→pos_integer(),gte?: a, lte?: b→a..b