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:

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:

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 embeds

Fixed

Changed


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(: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:

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, [...]}

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


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)
})

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)

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{})

Changed


0.1.0 — unreleased

First public release.

Spec algebra

Registry

Coercion pipeline

Generator inference

Function signature checking

Typespec bridge