Weird Method Signatures in Ruby with Keyword Arguments

Kwargs are great for helping developers understand a method signature, but if they're not used carefully they result in confusing error messages.

Weird Method Signatures in Ruby with Keyword Arguments
Photo by Joshua Fuller / Unsplash

In a recent project, I was working with a method call from a library that used some meta-programming to define a get method. I wasn't certain what the correct signature was, so I thought I'd try some things out.

api_client.get('my/path')
# Success, got at 200!

I knew that the method accepted a request body but I wasn't sure how to pass the body. So I tried this next call.

api_client.get('my/path', 'body')
# => ArgumentError: wrong number of arguments (given 2, expected 1)

Okay, this tells me something, but I'm a bit confused. How did it expect one argument when I know a successful call can have more than one argument?

So I tried this, something you might recognize from a Ruby 2 project (more on that later):

api_client.get('my/path', { body: 'body' })
# => ArgumentError: wrong number of arguments (given 2, expected 1)

The same error! What does that tell us? It wasn't immediately obvious to me so I tried the next iteration:

api_client.get('my/path', body: 'body')
# Success, got at 200!

Let's break down what's happening here

  1. We're using a variable called api_client in this example. Pretend this is a wrapper that can make HTTP calls to a remote service.
  2. We're calling the get method which presumably uses HTTP GET to fetch data.
  3. The get method accepts a positional argument, a string, that represents the URL path we're fetching.

All good so far, right? The next step is where things get weird.

What is the next argument? I didn't know until I tried all the options. It turns out body: is a keyword argument, or kwarg, and it has a default. So the method signature must look something like this.

def get(path, body: {}, headers: {})
  # Perform get request
end

So what's with this error? ArgumentError: wrong number of arguments (given 2, expected 1) The method only requires one positional argument to work. So even though it can accept three arguments, it is only expecting one. The result is a message that is a bit misleading because you can correctly call the method with one, two, or three arguments, but the second and third need to be keywords.

Let's Make Things Weird

In Ruby land, especially applications that existed before Ruby 2, it is reasonable to see a method signature like this.

def get(path, options: {})
  body = options[:body]
  headers = options[:headers]
  # Perform get request
end

Calling this method can look the same as a call with kwargs because the curly braces around a hash in Ruby are optional. That's why I tried wrapping the second argument in a hash. I wanted to know if we were dealing with an options hash or a keyword.

As a consumer of an interface like this, it can take a bit of poking around to know if you're dealing with an options hash or keywords.

Let's Make Things Better

Kwargs are great for helping developers understand a method signature, but if they're not used carefully they result in confusing error messages.

To make this method call better, I'd suggest defining it entirely with kwargs and doing away with the positional argument.

def get(path:, body: {}, headers: {})
  # Perform GET
end

Let's see what usage looks like on this. Let's do something wild and call the method with no arguments.

get
# => ArgumentError: missing keyword: :path

Oh! That's helpful. I need to give it a path.

get(path: 'my/path')
# Success!

What about some obviously wrong method calls?

get(path: 'my/path', 'body')
# SyntaxError: unexpected ')', expecting =>
# get(path: 'my/path', 'body')

get(path: 'my/path', { body: 'body' })
# SyntaxError: unexpected ')', expecting =>
# get(path: 'my/path', { body: 'body' })

SyntaxError! Neat! Because Ruby knows this method needs keywords, it doesn't know how to parse these two calls. This prevents you from even calling the method at all.

Let's make a typo:

get(path: 'my/path', bdy: 'body')
# => ArgumentError: unknown keyword: :bdy

That's helpful too! It puts the typo right in front of my eyes.

And finally, let's use all keywords:

get(path: 'my/path', body: 'body' )
# Success!

But Why is it This Way? A History of Keyword Arguments in Ruby

Before keyword arguments existed in Ruby, the method signature would have had an options hash, as I noted above.

def get(path, options = {})
  headers = options[:headers]
  body = options[:body]
  # insert remaining http client code here
end

We had a few options for how we called the method, depending on our syntax preferences. Most commonly, you'd see something like this:

get('my/path', body: {}, headers: {})

But if you prefer to be explicit about what you are passing to a method you might wrap the hash in curly braces:

get('my/path', { body: {}, headers: {} })

Enter Keyword Arguments

Keyword arguments were introduced in Ruby 2.0. Ruby 2!!!! "Isn't that like eons ago?" you say. Yes, it is eons ago, but code practices change very slowly. So the two examples above were very common in Ruby 2.

In fact, in early versions of Ruby 2 you could define a method using kwargs and call the method with a hash. Ruby would see the hash and automatically convert it to kwargs.

So that meant a method like this:

def get(path, body:, headers:)
end

Could be called like this:

get('my/path', body: 'body', headers: { auth: 'auth' })

Or like this:

get('my/path', { body: 'body', headers: { auth: 'auth' } })

Ruby would figure out that the second argument, the hash, is the keyword argument. To the developer using the get method, there is no difference between using kwargs or an options hash, provided the use the right keywords.

Additionally, developers often chose to use a hash as the last positional argument for flexibility. Imagine an options hash with five or more options. Using kwargs the method signature would make the method unreadable!

def get(path, body:, headers:, retry:, retry_delay:, admin:, on_error: ...)
end

A contrived example of a long kwargs list

With kwargs users need to know the kwargs and the method signature is locked into the kwargs that the developer chose. But with an options hash, there is some flexibility to add or remove options if things change.

But in Ruby 3 all that changed and Ruby stopped automatically converting positional hashes to keyword arguments.

Today we get a slightly ambiguous message when we use signatures that mix positional and keyword arguments, but in Ruby 2, a positional argument could be converted to a keyword argument unexpectedly. An excerpt from the post above shows how keyword arguments were confusing:

Automatic conversion does not work well when a method accepts optional positional arguments and keyword arguments. Some people expect the last Hash object to be treated as a positional argument, and others expect it to be converted to keyword arguments.

Let's rewrite the get method to demonstrate how this can be confusing. (Note: this is my rewording of the example that the authors of the Ruby blog post wrote).

# Using Ruby 2.6
def get(path, body: {}, headers: {})
  p [ path, body, headers ]
end

get({})
# => [ {}, {}, {}]

def get_with_default(path = 'home', body: {}, headers: {})
  p [ path, body, headers ]
end

get_with_default({})
=> ["home", {}, {}]

get_with_default has an unexpected behaviour here. Since we're only passing one argument, the method should see that as the first positional argument. But because we're passing it a hash, it parses this hash as the keyword arguments. Let's try to put something in that hash to test this out.

# Again, we're in Ruby 2.6 here!

get_with_default({body: 'body'})
=> ["home", 'body', {}]

There is a reason for this, but the behaviour is not intuitive. Since the first argument has a default, we don't need to pass it, so when Ruby 2.6 sees a hash it automatically parses the hash as keyword arguments.

In the Ruby blog post about separating positional and keyword arguments, they suggest a workaround one might think is clever but produces unexpected results as well.

# Still Ruby 2.6 here

get({}, **{})
=> expected: [ {}, {} ]
=> actual:  [ 'home', {} ]

The **{} here is telling Ruby, "I'm passing you a hash and I'd like you to treat it as keyword arguments." However, Ruby just ignores it because the parser sees the first hash and uses that as the kwargs!

To quote the Ruby post you need to call get({}, {}) to get this to work, "which is very weird."

# Still Ruby 2.6 here

get({}, {})
=> expected: [ {}, {} ]
=> actual:  [ {}, {} ]

Enter Ruby 3

And finally! Here we are at Ruby 3 where you can't mix hashes with kwargs...err, wait..but you can still mix positional arguments with kwargs!

Summing Up

Ruby method signatures can be kinda funky, especially when they're peppered with the history of the language itself.

My take here is that we should always write the most human-intelligible code possible. If you have a simple method call where the arguments are straightforward, positional arguments are fine.

However, it helps to name things, so go with keyword arguments all the way. Mixing the two can get a bit messy.