Ash builds its error messages inside the action, so you cannot wrap them in a Gettext call yourself. This page shows how AshGettext translates them for you, which errors it covers, and how to turn it on one domain at a time.

Turn it on

Add the AshGettext.Domain extension and a gettext block to the domain:

defmodule MyApp.Shipping do
  use Ash.Domain, extensions: [AshGettext.Domain]

  gettext do
    backend(MyApp.Gettext)
  end

  resources do
    resource MyApp.Shipping.Parcel
  end
end

backend is the only required option. See the DSL reference for the rest.

Add the AshGettext.Resource extension to each resource:

use Ash.Resource,
  domain: MyApp.Shipping,
  extensions: [AshGettext.Resource]

The domain block says how to translate. The resource extension turns it on for that resource's create, update and destroy actions.

mix ash_gettext.install makes both edits in every domain and resource. Run it alone to see the edits, and with --apply to write them. If the app has more than one Gettext backend, name one with --backend MyApp.Gettext.

See one error in two languages

create :create do
  accept [:weight]
  validate compare(:weight, greater_than_or_equal_to: 30),
    message: "must weigh at least %{greater_than_or_equal_to} kg"
end
# priv/gettext/ru/LC_MESSAGES/ash.po
msgid "must weigh at least %{greater_than_or_equal_to} kg"
msgstr "должен весить не меньше %{greater_than_or_equal_to} кг"

Run the action with actor:. The error comes back in the actor's language:

MyApp.Shipping.Parcel
|> Ash.Changeset.for_create(:create, %{weight: 5}, actor: user)
|> Ash.create()
# {:error, %Ash.Error.Invalid{errors: [%{message: "должен весить не меньше 30 кг"}]}}

With no actor, Gettext's current language is used, so wrap the call in Gettext.with_locale/3. No action changes the process language. The domain's resolver reads the language off the actor; see Locales. A resolver must not query the database.

No handler on each action

Without AshGettext, each write action needs its own handler, and a new action ships untranslated if someone forgets it:

create :register do
  accept [:email]
  error_handler {MyApp.Errors, :translate, []}
end

The resource extension adds the handler to every create, update and destroy action, including actions you add later. An action that declares its own error_handler keeps it, and is not translated.

The message is the msgid

An error's message: is looked up in the catalog as written:

validate present(:email), message: "can't be blank"

If the catalog for the reader's locale has an entry, the entry replaces the message. If it has none, the message is left as it was. Adding AshGettext can add translations. It never changes a message that has no entry.

Your own language needs no catalog, because the message is already the text. The .po files hold only the other languages.

Ash's own errors

Some of Ash's errors have no message. Ash prints them from code names: a missing required value reads "attribute delivery_date is required". AshGettext gives each one a written English sentence and translates it:

ErrorSentence
Ash.Error.Changes.Requiredis required
Ash.Error.Changes.InvalidAttributeis invalid
Ash.Error.Changes.InvalidArgumentis invalid
Ash.Error.Forbidden.PolicyYou are not allowed to do this
{:error, error} = Ash.create(MyApp.Order, %{}, actor: user)
error = hd(error.errors)

error.message             #=> "обязательное поле"
Exception.message(error)  # ends with "обязательное поле"
error.field               #=> :delivery_date
error.original            #=> %Ash.Error.Changes.Required{...}

The error comes back as an AshGettext.AshMessages.Translated. It keeps Ash's error in original, with its field and class. JSON:API renders the original error with the translated sentence as detail.

mix gettext.extract adds these sentences to the template. A policy with its own message keeps it, and that message is its msgid. A stale record error comes from the data layer after the handler, so it is not translated.

Put values in a message

Write %{name} in the message and pass the values with vars:

InvalidAttribute.exception(
  field: :weight,
  message: "must weigh at least %{min} kg",
  vars: [min: 30]
)
msgid "must weigh at least %{min} kg"
msgstr "должен весить не меньше %{min} кг"

A message built with #{...} cannot be a msgid, because the catalog needs a constant key. mix gettext.extract warns about each one so you can rewrite it with %{name} and vars.

Know which actions are translated

Only create, update and destroy actions are translated. Ash gives those three an error_handler and gives read and generic actions none. Errors from a read or a generic action reach the caller as written. If you need a translated read error, translate it where you render it, with AshGettext.ErrorTranslation.translate/2.

Choose the catalog file

text_domain names the Gettext domain, which is the .po file the messages live in. It defaults to "ash", the same catalog that holds enum labels and attribute names:

gettext do
  backend(MyApp.Gettext)
  text_domain("shipping")
end

Messages are now looked up in priv/gettext/<locale>/LC_MESSAGES/shipping.po.

Roll out one domain at a time

translate_errors? switches translation off for a domain that has a gettext block:

gettext do
  backend(MyApp.Gettext)
  translate_errors?(false)
end

Its resources keep their messages exactly as written. Remove the line when you are ready.

require_resource_extension? is the opposite check. When true, compiling the domain warns about every resource that lacks the AshGettext.Resource extension:

gettext do
  backend(MyApp.Gettext)
  require_resource_extension?(true)
end

The warning is an error under mix compile --warnings-as-errors. It is off by default, because Ash extensions are opted into.

Return a coded error

AshGettext.Error is an error with a stable code the client can branch on and a message that is translated:

{:error,
 AshGettext.Error.exception(
   code: :weight_too_low,
   field: :weight,
   message: "must weigh at least %{min} kg",
   vars: [min: 30]
 )}

code is required. Leaving it out raises ArgumentError. field and vars are optional. The message is translated like any other, so return the error from a create, update or destroy action.

With :ash_json_api installed, this error renders as:

{
  "code": "weight_too_low",
  "detail": "must weigh at least 30 kg",
  "source": {"pointer": "/data/attributes/weight"},
  "meta": {"min": 30}
}

code is the atom as a string, detail is the translated message and meta holds vars. There is no title, so no untranslated English reaches the reader. source.pointer appears only when field is set.