Validation and changeset errors

Copy Markdown View Source

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"}
  ]
}
  • :code is Ecto's :validation option when present, then :constraint, then :invalid as a fallback. Stable across locales and Ecto versions — frontends key their translations on it.
  • :params is the original Ecto error options with :validation / :constraint stripped (they're promoted to :code). Carries :count, :kind, :max, :number, etc. that translation templates interpolate.
  • :message is 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.Gettext

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