This coding adventure explores some of the logic behind the simple method I18n#t that we use on Ruby on Rails applications.

This adventure is the result of some frustration after carefully parsing hundreds of locale files in a big Rails application, and pushing hotfixes to production due to missing interpolations in a translated string.

The exact details of the hotfixes or exceptions in production are not relevant, but when you work with a lot of teams that change code and translation at the same time, you begin to wonder if there is a better way to avoid raising 500 errors in your server.

So let’s start.

The adventure

Given:

en:
  foo: 'Hello %{place}'

Then calling:

I18n.t('foo')
# => "Hello %{place}"

When interpolated:

I18n.t('foo', place: 'world!')
# => "Hello world!"

With additional variables:

I18n.t('foo', place: 'world!', time: '15:00')
# => "Hello world!"

With a variable except the one declared:

I18n.t('foo', time: '15:00')
# => I18n::MissingInterpolationArgument
# (missing interpolation argument :place in "Hello %{place}"
# ({:time=>"15:00"} given))

Nil values work:

I18n.t('foo', place: nil)
# => "Hello "

Also empty strings:

I18n.t('foo', place: '')
# => "Hello "

This is a bad idea:

args = { place: nil, time: '15:00' }
I18n.t('foo', args.compact)

This is a good one, but it doesn’t work:

I18n.t('foo', time: '15:00', default: 'Hello universe!')
# => I18n::MissingInterpolationArgument
# (missing interpolation argument :place in "Hello %{place}"
# ({:time=>"15:00"} given))

What if:

module I18n
  def self.t(key, opts)
    super(key, **opts)
  rescue I18n::MissingInterpolationArgument
    opts[:default] || nil
  end
end

So:

I18n.t('foo', time: '15:00', default: 'Hello universe!')
# => "Hello universe!"

But consider:

I18n.t('foo', default: 'Hello universe!')
# => "Hello %{place}"

Instead of hacking our way into the class method, let’s handle the exception in a better way.

Of course there’s an option for this ⧉:

# lib/i18n/config.rb
#
# Sets the missing interpolation argument handler. It can be any
# object that responds to #call. The arguments that will be passed to #call
# are the same as for MissingInterpolationArgument initializer. Use +Proc.new+
# if you don't care about arity.
#
# == Example:
# You can supress raising an exception and return string instead:
#
#   I18n.config.missing_interpolation_argument_handler = Proc.new do |key|
#     "#{key} is missing"
#   end
def missing_interpolation_argument_handler=(exception_handler)
  @@missing_interpolation_argument_handler = exception_handler
end

So we can do this:

# app/initializers/i18n.rb
#
I18n.config.missing_interpolation_argument_handler = Proc.new do |key|
  I18n.t('default_missing')
end

With this:

en:
  foo: 'Hello %{place}'
  default_missing: 'missing key'

Let’s see:

I18n.t('foo', time: '15:00')
# => "Hello missing key"

I really wish the output would be just missing key.

Let’s assume that this configuration option works for us, some nice stuff we can do. For example, send a notification to Honeybadger (or your preferred monitoring tool):

# app/initializers/i18n.rb

handler = Proc.new do |missing_key, provided_hash, string|
  Honeybadger.notify('Missing interplation argument', content: {
    missing_key: missing_key,
    provided_hash: provided_hash,
    string: string
  })
  I18n.t('default_missing')
end

I18n.config.missing_interpolation_argument_handler = handler

That’s nice. But I want to return any text, not the missing key replacement during the interpolation. Or the default text for what is worth:

I18n.t('foo', time: '15:00', default: 'Hello world!')
# => "Hello world!"

We can see how it works in the I18n#interpolate method in this file ⧉. Specifically here:

key = ($1 || $2 || match.tr("%{}", "")).to_sym
value = if values.key?(key)
          values[key]
        else
          config.missing_interpolation_argument_handler.call(key, values, string)
        end
value = value.call(values) if value.respond_to?(:call)
return $3 ? sprintf("%#{$3}", value) : value

Code can be a little bit cryptic but the important line is the following: sprintf("%#{$3}", value). This line takes care of the interpolation using sprintf.

sprintf('Hello %{place}', { place: "world!" })
# => "Hello world!"

Consider the previous cases:

sprintf('Hello %{place}', { time: '15:00' })
# `sprintf': key{place} not found (KeyError)

sprintf('Hello %{place}')
# `sprintf': one hash required (ArgumentError)

I18n is also a nice sprintf wrapper. It handles exceptions for us, including some weird ones Ruby has, e.g. reserve keywords ⧉. Besides of course a lot of other things.

Eyes on the prize, let’s try to achieve our goal. Quick and dirty, like a PoC or that feature you rushed to release to production.

module I18n
  class << self
    def t(key, opts)
      result = super(key, **opts)
      raise I18n::MissingInterpolationArgument if missing_interpolations?(result)
      result
    rescue I18n::MissingInterpolationArgument
      Honeybadger.notify(key, context: opts)
      opts[:default] || key # Fallback to key if no default is given
    end

    def missing_interpolations?(key)
      key.is_a?(String) &&
      key.match?(Regexp.union(I18n.config.interpolation_patterns))
    end
  end
end

So now given:

en:
  foo: 'Hello %{place}'

Then calling:

I18n.t('foo', place: 'world!')
# => "Hello world!"

I18n.t('foo', place: 'world!', time: '15:00')
# => "Hello world!"

I18n.t('foo', default: 'Hello universe!')
# => Honeybadger.notication
# => "Hello universe!"

I18n.t('foo', time: '15:00', default: 'Hello universe!')
# => Honeybadger.notication
# => "Hello universe!"

I18n.t('foo', time: '15:00')
# => Honeybadger.notication
# => "foo"

And that’s the end of this adventure.

A word of wisdom

Please don’t use the monkey-patch in production, it’s not ready at all. I know I will, but that’s on me.