# Validation and changeset errors

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:

```elixir
%{
  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`:

```elixir
# 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

```elixir
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:

```elixir
{: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:

```elixir
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:

```ts
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`:

```svelte
{#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](livesvelte.md) 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.
