tekin.co.uk

10 Things You Might Not Know About Rails i18n

This is mostly a transcript of my recent lightning talk on i18n in Rails, go watch that if you prefer your content in video form. Otherwise read on to find out why I believe understanding i18n in Rails can be useful to you, a Rails developer, even if you’re not translating your applications into other languages.

I’m going to share ten things I think are interesting in i18n, and explain how we can take advantage of them in our single language app code. But first a brief primer on i18n…

What actually is i18n?

i18n is shorthand for Internationalisation When we’re talking about i18n, it’s useful to understand the difference between Internationalisation and Localisation:

  • Internationalisation — the process of abstracting content and other locale-specific things away from application code itself
  • Localisation — the process of adapting software to support different languages and regions (maybe using i18n)

The take away here is that although i18n is primarily a framework for adapting applications to support other languages, the core idea of separating content from applications code has useful properties that we can take advantage of it to improve our Rails applications, even if they only support a single language.

i18n in Rails

Rails ships with the i18n gem, and there are two main components:

  • locale files where textual content is organised and stored per-language (normally as YAML)
  • two helper methods:
    • I18n.translate for outputting text content from the locale files
    • I18n.localize for localising dates and times

Rails aliases these helpers in views and helper modules to the convenient shorthands of t and l respectively.

So the most basic usage of i18n would look something like this:


# app/config/locales/en.yml
en:
  home_title: Hello world
  home_body: How are you doing?

<!-- app/views/home/index.html.erb —>

<h1><%= t 'home_title' %></h1>
<p><%= t 'home_body' %></p>

<time datetime="<%= @published_at.iso8601 %>">
  <%= l @published_at %>
</time>

Primer over! On to the ten things…

Thing 1: You are already using it!

Even if you’re not localising your app you’re almost certainly still using i18n. That’s because it’s baked into the very fabric of the framework.

For example Active Record uses i18n under the hood to generate error messages for its validations. It does so using a YAML file that ship with Active Model:


# rails/activemodel/lib/active_model/locale/en.yml
en:
  errors:
    # The default format to use in full error messages.
    format: "%{attribute} %{message}"

    messages:
      model_invalid: "Validation failed: %{errors}"
      inclusion: "is not included in the list"
      exclusion: "is reserved"
      invalid: "is invalid"
      confirmation: "doesn't match %{attribute}"
      accepted: "must be accepted"
      empty: "can't be empty"
      blank: "can't be blank"
      ...

Whenever you see something like “Title cannot be blank”, this is the i18n plumbing that was used to construct it.

Thing 2: Default error message can be overridden

Rails i18n makes it easy to override these default errors messages. Just replicate the same key structure in your application’s locale file with your chosen phrasing:


# app/config/locales/en.yml
en:
  errors:
    messages:
      # Overrides the default Rails message for presence validations
      blank: "must be provided"

Rails will check your application’s locale file first, falling back to the defaults only if the message isn’t defined there.

Model and attribute specific overrides

As well as overriding this global phrasing, it’s also possible to override the error messages for a given attribute name:


# app/config/locales/en.yml
en:
  errors:
    attributes:
       title: # phrasing will apply to any attribute called "title”
           blank: "must be provided”

Or you can get even more granular and override specific attributes on a given model. For example if we have the following model and validation:


class Album < ApplicationRecord
  validates :category, presence: true
end

It’s possible to set a bespoke error message for this specific model and attribute:


# app/config/locales/en.yml
en:
  activerecord:
    errors:
      models:
        album: # model name
          category: # attribute name
            blank: "must be selected"

Thing 3: Overriding the error message format itself

As well as overriding individual validation messages globally and for specific attributes, it’s also possible to change the actual format of error messages. By default, errors are constructed with the familiar format of "%{attribute} %{message}". This is defined in the same YAML that ships with Active Model alongside the default error messages:


# rails/activemodel/lib/active_model/locale/en.yml
en:
  errors:
    # The default format to use in full error messages.
    format: "%{attribute} %{message}"

This format works as a reasonable default to ship with the framework as it gives us mostly coherent error messages out of the box without us having to do anything, but it lacks fidelity and can result in some clunky and unfriendly sounding messages. For example, take this validation:


class Album
  validates :number_of_tracks, comparison: { greater_than: 0 }
end

If we go with the default format we end up with "Number of tracks must be greater than 0". Which is fine, but we can definitely do better.

My personal preference on the apps I work with is to remove the attribute prefix entirely:


# app/config/locales/en.yml
en:
  errors:
    # Remove the attribute name from the default error message format:
    format: "%{message}"

This frees me from having to make my error messages work with attribute’s name upfront, allowing me to write more user-friendly and coherent error messages:


# app/config/locales/en.yml
  activerecord:
    errors:
      models:
        album:
          number_of_tracks:
            greater_than: "You must upload at least one track"

Now changing the default format does come at a cost: without the attribute name prefix, the default messages that ship with Rails no longer form complete sentences. This forces me to write bespoke error messages for each validation I add to my app:


# app/config/locales/en.yml
en:
  activerecord:
    errors:
      models:
        album:
          title:
            blank: "Enter a title for the album"

Personally I’m happy to pay this cost for the benefit of improving the user experience. So whilst you may not want to do this on an existing app with thousands of error messages to backfill, it might be something you consider next time you run rails new and are starting from a clean slate.

Update: There’s actually a fairly neat way to avoid this cost, check out my follow-up post on how.

Thing 4: Using i18 for your custom validations

As well using i18n to manage and override the error messages for the built-in validations, you can also use it to manage the messages for your custom validations.

Let’s look at a custom model validation:


class DirectDebitMandate < ApplicationRecord
  validate :bank_account_must_pass_modulus_check

  private

  def bank_account_must_pass_modulus_check
    if modulus_check_fail?
      errors.add(:bank_account_number, "The account number you entered is not valid")
    end
  end
end

Here the error message is inlined as part of the validation code. Instead of doing this, we can pass in a symbol identifying the error, and define the corresponding message in the locale file using the same key structure as the built-in validations:


class DirectDebitMandate < ApplicationRecord
  validate :bank_account_must_pass_modulus_check

  private

  def bank_account_must_pass_modulus_check
    if modulus_check_fail?
      errors.add(:bank_account_number, :failed_modulus)
    end
  end
end

# app/config/locales/en.yml
en:
  activerecord:
    errors:
      models:
        direct_debit_mandate:
          bank_account_number:
            failed_modulus: "The account number you entered is not valid"

This results in more concise and compact model code, but also equally as nice is that it removes a presentational concern — the specific phrasing of the message — our the business logic.

It’s also possible to interpolate both the attribute name and the value of the attribute directly into the error message, which is useful for custom validations that are used across multiple attributes and/or models:


# app/config/locales/en.yml
en:
  activerecord:
    errors:
      messages:
        failed_modulus: "The %{attribute} '%{value}' is not valid"

Thing 5: Form labels

Another place where Rails makes use of i18n under the hood is the form helpers: specifically, the label helper methods. By default the label helper methods humanize the attribute name to arrive at the label text:


<%= form.label :address_county %> # label text will be "Address county"

One way to override the label text is to pass in a string to the helper call like so:


<%= form.label :address_county, "County (optional)" %>

I think a better way is to define label overrides in the application’s locale file:


# app/config/locales/en.yml

helpers:
  label:
    order: # the model name
      address_county: 'County (optional)'

Not only does this keep the view code more concise and less noisy, but it has the added advantage of defining this label override in one place: now any other forms that display the same label will automatically get the overridden text without you having to manually copy the literal string to every template.

And as with form errors, label overrides can be defined per-model as well as globally:


# app/config/locales/en.yml

helpers:
  label:
    address_county: 'County (optional)' # applies to all models with attributes called address_county

Thing 6: Built-in pluralisation

i18n has sophisticated support for pluralisation that we can leverage to simplify our app code. As an example, we can display the stock availability of a product by calling out to i18n like so:


<!-- app/views/albums/show.html.erb —>

<h1><%= @album.name %></h1>

<div class="availability">
  <%= t 'album.availability', count: @album.stock.count %>
</div>

And then define the phrasing based on the number of items in stock in our locale file:


# app/config/locales/en.yml
en:
  album:
    availability:
      zero: "Out of stock"
      one: "Just one album left in stock!"
      other: "%{count} albums in stock"

Much neater than a bunch of conditional logic in a helper method!

Thing 7: Action Mailer support

As well as being available in Rails controllers and views, i18n is also integrated into Action Mailer, allowing you to organise content for mailer templates in local files. Mailer get one extra feature of controllers: the ability to specify email subject lines in locale files:


# app/config/locales/en.yml

en:
  membership_mailer: # mailer name
    submitted: # mailer action
      subject: "Welcome to to the union!"
    dropped:
      subject: "Want to complete your membership application?"

Again, this enables us to move another presentational concern out of our application code and put it somewhere more appropriate.

There is also a meta benefit to defining all these bits of content (mailer subject lines, form labels, validation error messages) in an application’s locale file: it puts them all in one centralised place, making them easier to change, check for consistency and generally manage. It also makes the content more accessible to non-developers such as designers and product folks.

Thing 8: Time and date formatting

Right at the top I mentioned the I18n.localize method that is used to output localised times and dates. The way this works is by defining our own time and date formats in our locale file using the same format specification as Ruby’s strftime:


# config/locales/en.yml
en:
  date:
    # ...
    formats:
      short: "%d/%m/%Y"

Here we’ve defined a short date format that will be familiar to my UK-based audience: day/month/year. To render a date using this format we simply pass in the format identifier to I18n.localize (or its l alias):


<%= I18n.localize @album.released_on, format: short %>

or with the l shorthand:


<%= l @album.released_on, format: short %>

UK vs US dates…

Now my American readers may be confused by this short format, as across the pond the preferred way to display dates is actually month/day/year. So whilst you may not need to localise your entire application, you might want to consider localising your date rendering, especially if you have customers both sides of the Atlantic.

To do this you would define a US region specific English locale file:


# config/locales/en-US.yml
en-US:
  date:
    # ...
    formats:
      short: "%m/%d/%Y"

Then update your controller code to automatically switch locale so the user gets dates formatted appropriately for them.

There are many ways to do actually do the locale switching, but at a high-level, you identify the user’s specific locale and then make sure your controller actions are executed with that locale. Here’s an example of doing that by sniffing the user’s locale from their browser headers:


class ApplicationController < ActionController::Base
  around_action :switch_locale

  private

  def switch_locale(&action)
    browser_locale = extract_locale(request.env['HTTP_ACCEPT_LANGUAGE'])

    I18n.with_locale(browser_locale, &action)
  end
end

(The exact mechanics of the extract_locale method is left as a separate exercise, but you could do worse than use the http_accept_language library)

Thing 9: You can define locale files in Ruby

Little known fact: the i18n library supports locale files defined using Ruby! Now why would you do this, other than a burning hatred for YAML? Well one reason is that Ruby-based locale files allows you to make use of procs to dynamically generate content.

Below is an excerpt from a Ruby-based locale file from one of my applications. It uses a proc to format dates as academic years, changing the output depending on which side of September the date is:


# config/locales/en.rb

{
  en: {
    date: {
      formats: {
        academic_year: ->(date, _) {
          year = date.year
          date.month >= 9 ? "#{year}/#{year + 1}" : "#{year - 1}/#{year}"
        }
      }
    }
  }
}

Now this could just as easily be achieved using a standard Rails helper method. But for me I like the consistency and clarity of using a single mechanism (localize) for formatting dates across my application code, rather than with a mishmash of calls to bespoke helpers, DateTime#to_fs, strftime, etc.

Thing 10: …

This is the part of the lightning talk where I make a meta point about what I’ve covered. I won’t write that up here, but if you’re interested go watch the video from minute 9.

Get more fab content like this straight to your inbox

You'll get an email whenever I have a fresh insight or tip to share. Zero spam, and you can unsubscribe whenever you like with a single click.

More articles on Ruby & Rails

Authored by Published by