Caravela's generated forms and REST controllers translate
Ecto.Changeset errors into a structured, i18n-ready shape via
Caravela.ChangesetTranslator. The shape is semver-stable from
v0.12 onward.
The error shape
Every field's error list is a sequence of structured maps:
%{
title: [
%{code: :required, params: %{}, message: "can't be blank"}
],
pages: [
%{code: :number, params: %{kind: :greater_than, number: 0}, message: "must be greater than 0"}
]
}:codeis Ecto's:validationoption when present, then:constraint, then:invalidas a fallback. Stable across locales and Ecto versions — frontends key their translations on it.:paramsis the original Ecto error options with:validation/:constraintstripped (they're promoted to:code). Carries:count,:kind,:max,:number, etc. that translation templates interpolate.:messageis the rendered string. Filled by the configured translator (below), or by the built-in pass-through interpolator that replaces%{param}placeholders.
Gettext integration
Apps using Gettext plug their backend in config.exs:
# config/config.exs
config :caravela, :changeset_translator, MyAppWeb.GettextCaravela calls MyAppWeb.Gettext.dgettext("errors", template, params)
for singular messages and MyAppWeb.Gettext.dngettext("errors", singular, plural, count, params) when Ecto signals a plural via the
:count option — the same contract Phoenix's own
ErrorHelpers.translate_error/1 uses. Existing
priv/gettext/<locale>/LC_MESSAGES/errors.po locale files work
unchanged.
Any module that exports dgettext/3 and dngettext/5 is a valid
translator — Gettext backends are the common case but not a
requirement.
Per-call override
Caravela.ChangesetTranslator.translate(changeset, translator: OtherBackend)
# Or force the pass-through path (no translation) — useful in tests:
Caravela.ChangesetTranslator.translate(changeset, translator: false)Using it outside generated code
Caravela.ChangesetTranslator.translate/2 is a public helper. Call it
from any controller or LiveView that needs the structured shape — it
isn't tied to Caravela-generated code:
{:error, %Ecto.Changeset{} = cs} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: Caravela.ChangesetTranslator.translate(cs)})translate_error/2 is also exposed for callers with their own
changeset traversal:
Caravela.ChangesetTranslator.translate_error(
{"is invalid", [validation: :format]},
translator: false
)
#=> %{code: :format, params: %{}, message: "is invalid"}On the frontend
Generated Svelte form components (BookForm.svelte) receive errors
as:
errors?: Record<string, Array<{ code: string; params: object; message: string }>>;A simple renderer reads :message directly; a localized renderer keys
on :code and interpolates :params:
{#each errors.title ?? [] as err}
{#if err.code === 'required'}
<span class="error">{$_('errors.required')}</span>
{:else if err.code === 'length'}
<span class="error">
{$_('errors.too_short', { values: { count: err.params.count } })}
</span>
{:else}
<span class="error">{err.message}</span>
{/if}
{/each}The :rest REST controller and the :live LiveView form both
produce this shape, so the same Svelte error component works under
either transport. See svelte frontend for the full
prop contract.
Migration note (v0.11 → v0.12)
Prior to v0.12 the generated templates delegated to
CaravelaSvelte.Caravela.errors/1, which produced the flat Phoenix
%{field => [msg]} shape. That helper still exists in
caravela_svelte for backward compatibility, but Caravela-generated
code no longer uses it. Re-running mix caravela.gen.live on an
existing domain rewrites the templates to use
Caravela.ChangesetTranslator and threads the structured shape
through the Svelte components' errors prop.