Hash and Keyword Coercion and Ruby 3 Changes

Aaron Rosenberg
Building RigUp
Published in
4 min readJan 13, 2020

--

Hash Coercion

To create an asynchronous link with the text 'download' and class 'btn-download' in the Ruby on Rails web framework you use the link_to method with the following arguments:

link_to('download', {remote: true}, class: 'btn-download')

Notice the {}s around remote: true. They are necessary because the link_to method takes an options hash for its behavior and an html_options hash for customizing the tag as its second and third arguments respectively. This syntax for passing two hash positional arguments works because Ruby "converts" all trailing key-value pairs into a hash. The above is equivalent to:

link_to('download', {remote: true}, {class: 'btn-download'})

This flexibility can be powerful but it can also cause confusion. For example, if you forget the {}s entirely (i.e. remote: true, class: 'btn-download'), Ruby won't raise an error! The arguments are optional so Rails will still generate an asynchronous link with the text 'download', but the HTML <a> element will not have a class and the URL will have '?class=btn-download' incorrectly appended to it.

This is a feature of the Ruby Language known as hash coercion and the reason why the following two calls are equivalent:

def foo(some_arg)
puts "#{some_arg.inspect}, #{some_arg.class}"
end
foo({one: 1, two: 2})
# => {:one=>1, :two=>2}, Hash
foo(one: 1, two: 2)
# => {:one=>1, :two=>2}, Hash

Keyword Coercion

Because Ruby follows The Principle of Least Surprise, keyword arguments were added in Ruby 2.0 in order to make intent clearer. Keyword arguments are the named final arguments to a method which follow any positional arguments. As hashes were often used for named arguments and traditionally placed last, Ruby made it easy to adopt the newer keyword argument syntax without having to change all the method’s callers. This was accomplished by a new language feature called keyword coercion, demonstrated by the following method:

def bar(one:, two:)
puts "#{one}, #{two}"
end
bar(one: 1, two: 2)
# => 1, 2
# The "older" call with a hash literal still works
bar({one: 1, two: 2})
# => 1, 2
# So does a hash variable
bar_args = {one: 1}
bar_args[:two] = 2
bar(bar_args)
# => 1, 2
bar({ one: 1, three: 3 })
# => ArgumentError (unknown keyword: three)

Keyword coercion changes a hash literal or variable into keyword arguments by using the keys as the argument names and the values as the arguments’ values. Keyword coercion guards against errors as well by raising an ArgumentError if one of the hash's keys is not a known keyword value.

Unforeseen Consequences

These features have not been without confusing problems. One important overlooked aspect is that while mixed keys will work with hash coercion,

foo(one: 1, 'two' => 2)
# => {:one=>1, "two"=>2}, Hash

it requires all keys to be symbols for keyword coercion

bar(one: 1, 'two' => 2)
# => ArgumentError (wrong number of arguments (given 1, expected 0; required keywords: one, two))

Additionally, notice that the error says it is being passed a positional argument when none are expected. This is because the hash is being “broken up” into multiple hashes by key type. I certainly didn’t expect that and was quite surprised.

This can open your code up to situations where it is difficult to determine if hash or keyword coercion will happen. Look at the following method, its calls, and the output produced. Do they follow The Principle of Least Surprise?

def baz(one, two: 'two')
puts "#{one}::#{one.class}, #{two}::#{two.class}"
end
baz(one: 1, two: 2)
# => {:one=>1, :two=>2}::Hash, two::String
baz({one: 1, two: 2})
# => {:one=>1, :two=>2}::Hash, two::String
baz({one: 1}, two: 2)
# => {:one=>1}::Hash, 2::Integer
baz({one: 1}, {two: 2})
# => {:one=>1}::Hash, 2::Integer

The final two calls make sense and aren’t surprising but I feel the first two are a toss up as to how they could work.

The Times They Are a-Changin’

The Ruby Language team apparently feels the same and will be fixing this in Ruby 3.0 with Ruby 2.7 adding deprecation warnings for these particularly confusing syntax options. To be clear, calling baz with an implicit hash, as seen in the first call above, will continue to work in Ruby 2.7. However, it will also print the warning

warning: Passing the keyword argument as the last hash parameter is deprecated

You can find an exhaustive list of the warnings and suggestions on how to future proof your code quickly in the tl;dr section of the official Ruby Language blog post.

I have an even easier alternative. In general, explicit code that clearly indicate types, even when optional, is easier to understand. This is the proverbial rising tide which lifts all boats. Clear explicit code reduces the cognitive load on developers over relying on the entire team to remember all the possibilities, edge cases, and that all trailing non curly brace wrapped key-value pairs will be coerced into a hash.

Ultimately the stricter enforcement by separation of positional and keyword arguments will result in code whose meaning is absolutely clear on first read. At first glance, you simply cannot tell if func(one: 1, two: 2) takes a single hash positional argument like foo or keyword arguments like bar. However, the method definitions will be obvious from the following calls in Ruby 3:

foo({one: 1, two: 2})
# => {:one=>1, :two=>2}, Hash
bar(one: 1, two: 2)
# => 1, 2
# and
bar_args = {one: 1}
bar_args[:two] = 2
bar(**bar_args)
# => 1, 2

Hash coercion will still remain in Ruby and some will still strongly prefer foo(one: 1, two: 2) as it's in The Ruby Style Guide. Maybe that will change with Ruby 3 but you and your team can always decide for yourselves as Rubocop's Style/BracesAroundHashParameters cop is configurable. Finally, while foo(**{one: 1, two: 2}) will continue to work as intended, I'd hope your team would flag it in code review for being misleading.

--

--