Published on Wednesday, 2025-01-22 at 13:00 UTC
Translating Ecto Changeset Errors in Phoenix
Translating changeset errors is actually easy and you don’t have to mix web and context modules.
I recently received a great pull request for Keila, my Open Source email newsletter tool, that added a French translation. Even though I had previously translated Keila to German, it seems like I missed quite a few strings that should have been translated. One of the aspects I had previously neglected to translate were Ecto changeset errors from custom validations.
So in this how-to I’ll share what I learned when fixing this and how you can translate Ecto changeset errors in your own Phoenix applications.
How Not to Translate Changeset Errors
Every new Phoenix app comes with some macros based on the the Gettext library. Theese macros make translating an application quite easy.
If you want to translate a string in a Phoenix view, simply wrap it with the gettext()
macro like this: <%= gettext("Translate me!") %>
and it’s now translatable!
If you want to support
pluralization, you can use ngettext("an apple", "%{count} apples", n)
instead.
Thus, it might seem like a no-brainer to just import MyAppWeb.Gettext
in your schema module and
use gettext/2
or ngettext/4
directly in a custom validation like so:
# Don't do this!
defmodule MyApp.MySchema do
import MyAppWeb.Gettext
# ...
def changeset(data, params) do
data
|> cast(params, [:password])
|> validate_change(:password, fn :password, value ->
if value != "mellon" do
[password: gettext("this is not the right password")]
else
[]
end)
end
end
This, however, is not a good idea for two reasons:
- It mixes the web concerns (
MyAppWeb
) and the context concerns (MyApp
) - This approach translates the error directly at the source. This means if you're doing introspection or logging when something goes wrong unexpectedly, you'll have to deal with errors in a language you might not be familiar with.
So what could be a better solution?
How Translations Normally Work in Phoenix
The nice thing about the translation system that ships with Phoenix is that it can automatically extract all strings from your web application into gettext-compatible translation files.
Once you run mix gettext.extract
, your gettext-wrapped strings are added to a .pot
file in priv/gettext
.
By default, all strings from your views go into default.pot
. An entry in this file look like this:
msgid "Translate me!"
msgstr ""
This .pot
file is a template file and simply lists the strings available for translation.
That's why msgstr
is always empty here.
Next you can run mix gettext.merge
to create the actual .po
translation files for each language
you’ve configured. They can then be found in priv/gettext/#{locale}/LC_MESSAGES/
.
Here we can adjust the msgstr
that goes with every msgid
and add a translation:
msgid "Translate me!"
msgstr "Übersetze mich!"
If you don’t want all strings to be in the same translation file, you can specify a different domain
with dgettext/3
and dngettext/5
.
So the string from <%= dgettext("errors", "This is an error") %>
is going to be extracted into errors.pot
.
Speaking of errors.pot
- you might have noticed that Phoenix already ships with this file. And it contains
msgstr
s for all built-in Ecto validations!
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
So how does this work and how do we use it for our own validations?
Let’s do it the Phoenix way!
Recent versions of Phoenix initially create a handful of useful components
in lib/myapp_web/components/core_components.ex
. These include helpers like input/1
that render a form field with validation errors. The module also exports a function called translate_errors
which looks like this:
def translate_error({msg, opts}) do
if count = opts[:count] do
Gettext.dngettext(MyAppWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(MyAppWeb.Gettext, "errors", msg, opts)
end
end
In applications initialized with older versions of Phoenix, this same function can be
found atlib/myapp_web/views/helpers/error_helpers.ex
.
Here is a simplified version of how translate_error
is used by the input
component or how you might use it in your own custom component to show changeset
errors alongside an input field:
<div>
<input value={@field.value} id={id}>
<%= for error <- @field.errors do %>
<p class="bg-red-300"><%= translate_error(error) %></p>
<% end %>
</div>
For every error entry in the Phoenix.HTML.FormField
struct (which contains the changeset errors),
translate_error/1
is called. Beacause it then calls gettext
dynamically, the possible
error strings are not known at compile-time. This means, the strings cannot be extracted by
mix gettext.extract
. That’s why Phoenix ships with a default errors.pot
template!
Now that we understand this, we know that we have to add the msgstr
entry to
errors.pot
manually:
msgid "this is not the right password
msgstr ""
Now you can run mix gettext.merge
and add translations in the errors.po
files.
Your custom validation error messages are translated, the Phoenix way!
Translating more complex errors
Changeset errors can either be a plain string or a tuple with a
string and a keyword list (see the specs of validate_change/3
here). That’s also how we can get pluralized
validation messages. Let’s take this validation for example:
def changeset(data, params) do
data
|> cast(params, [:password])
|> validate_change(:password, fn :password, value ->
random = :rand.uniform(100)
[password: {"%{count} random error(s)", count: random}]
end)
end
To translate this error, you need to add this to errors.pot
:
msgid "%{count} random error(s)"
msgid_plural "%{count} random error(s)"
msgstr[0] ""
msgstr[1] ""
Note that the same string has to be used for msgid
and msgid_plural
. That’s because Ecto changeset errors
only consist of a single string with no native plural support.
But the way translate_error
is implemented, when the :count
keyword is present,
ngettext
is used with the value of :count
as the number that determines whether a plural
form needs to be used or not (n
).
The other keywords are also passed to gettext
which means they can be used as interpolation values.
Keep in mind that you can also add English "translation" strings in en/LC_MESSAGES/errors.po
like this even if the original language of your application is already English:
msgid "%{count} random error(s)"
msgid_plural "%{count} random error(s)"
msgstr[0] "one random error"
msgstr[1] "%{count} random errors"
Final Thoughts
As always in Phoenix, there isn’t really any magic to this. translate_error
is just
a regular function. It’s initially created by the Phoenix app generator, and you can
use it if you like or create your own implementation.
TL;DR: Manually add error msgstrs to errors.pot and use the translate_error function.