Achieve Static Typing Benefits in Ruby with Keywords Args and Class Constants
September 25, 2024 📬 Get My Weekly Newsletter ☞
Noel Rappin wrote an article on static typing in Ruby that does a great job outlining the various techniques to achieve the benefits often ascribed to static typing. I have two more techniques that address the 80% case of typing problems in Ruby: keyword arguments and class constants.
In my experience, most typing issues in Ruby and Rails apps are the result of overuse of hashes as data structures, coupled with the use of symbols to refer to classes instead of using the class itself. Both of these patterns result in indirection between intention and behavior. When you get it wrong—use the wrong hash key, call the wrong dynamically-created method—you get errors that don’t make sense.
Keyword Arguments Are Explicit and Ergonomic
Rails makes heavy use of options hashes. Consider
create_table
which has this signature:
create_table(table_name,
id: :primary_key,
primary_key: nil,
force: nil,
**options,
&block)
options
is documented as accepting a specific set of keys, and there is a fair bit of code written to validate these keys and
provide a decent error if you use one that’s not supported. This is a form of type checking. This API was designed before keyword arguments were a thing, so there really wasn’t a better way to do it.
Now, you could eliminate all that code by using this method definition:
create_table(table_name,
id: :primary_key,
primary_key: :id,
force: nil,
temporary: false,
if_not_exists: false,
options: {},
as: nil,
comment: nil,
charset: nil,
collation: nil,
limit: nil,
default: nil,
precision: nil,
&block)
Ruby will now tell you if you are mis-using the method. Rails would not need to provide any validation on the options because they
are part of the method signature (note the “escape hatch” options:
option, which allows free-form options to still be used).
Another common use of symbols—beyond hash keys for options—is as a stand-in for a class.
Classes Are Objects, too, and Darn Handy
Next, let’s look at Rails’ routing. Here is a typical route:
resources :widgets
This assumes there is a WidgetsController
that implements index
, show
, new
, create
, edit
, update
, and destroy
. It
also dynamically creates methods like widget_path
and edit_widget_path
.
In complex apps it can be hard to predict the names of the methods that will be created. It can also be easy to misspell something somewhere. Because the symbol :widgets
must carefully match substrings of various method names, be translatable into a class name, and match strings for URLs, there are a lot of ways this can be messed up. The error messages when that happens aren’t always helpful in understanding the problem.
What if we used the class itself, instead:
# config/routes.rb
resources WidgetsController
# some_view.html.erb
<%= link_to WidgetsController.show_path(widget) %>
Here, same is same. The resources
call is given an actual class that must have been defined. In that moment, resources
can check
to see if WidgetsController
has all the methods that it’s going to create routes for.
In the view template, again note that the use of WidgetsController
means that the class must exist. This is hard to mess up. If you do, a far better error message can be produced.
Rails’ configuration is littered with symbols masquerading as classes:
config.active_job.queue_adapter = :resque
Why not use the class directly?
config.active_job.queue_adapter = ActiveJob::QueueAdapters::ResqueAdapter
Yes, it’s more code, but you really only have to type it once. It’s easier for the developer to understand and easier for the framework to validate. See a longer post on this topic for more reasons why using actual structured code over indirection-through-symbols is a better user experience.
Additional Benefits
By being more explicit where possible, it can make refactoring easier, allow for better documentation, and facilitate better type checking if you need to actually do it.
Lean On The Interpreter
In a statically-typed language, certain refactors require changing type signatures of methods. When you do that, the compiler will show you everywhere that the method is now being misused. It’s a TODO list of everything you need to fix. When you’ve fixed everything, it’s often the case that most of the refactoring is done. Certainly, you need tests to be sure, but it provides precise direction on what needs changing.
You can get these benefits in Ruby by using keyword arguments and class constants.
Suppose Rails 9 wants to require comments be provided to every call of create_table
. By removing the default value in the method
signature, every app that doesn’t specify comment:
to create_table
will generate an error from Ruby that could not be
clearer: “create_table: missing keyword: :comment
”.
Suppose your company decides it’s not in the widgets business, but in the doodad business. No need to grep
for widget_
and
widgets_
and Widget
and :widget
and widget:
to find all the places someone could’ve referred to the resource.
Instead, change WidgetsController
to DoodadsController
and run the app. Ruby will tell you that you are referencing non-existent classes. It’ll tell you exactly where. You fix that and you might actually be done with what could’ve been a painful refactor. And you don’t even really need a test suite to do it, as long as you execute all the right code paths (though a test suite can make this much easier).
Self-Documenting Code
If you look at the documentation for create_table
, you’ll notice that the documented options are fewer than the actually-accepted
options. This is a benefit to using keyword arguments: at least their names will show up in documentation.
Using class constants is also a form of documentation. The hypothetical use of ActiveJob::QueueAdapters::ResqueAdapter
as the value
for the Active Job configuration option means you can look that class up easily and find its documentation. You can see how it’s built
and not have to figure out what :resque
actually means.
More Powerful Type Checking Code
If you do find the need to write additional type-checking code, you can give much better error messages. Suppose our imagined resources
method above existed. Instead of waiting until runtime to call methods and hoping the class was created properly, you can now easily check all that at the time of invocation:
ALL_METHODS = [
:index,
:show,
:new,
:create,
:edit,
:update,
:destroy,
]
def resources(klass, only: ALL_METHODS)
not_implemented = ALL_METHODS - klass.instance_methods
if not_implemented.any?
raise ArgumentError,
"#{klass} does not implement these methods: " +
#{not_implemented.map(&:to_s).join(', ')}. " +
"use `resources #{klass}, only: [ ... ]` "+
"to specify only those methods you support`"
end
if !klass.ancestors.include?(AbtractContoller::Base)
raise ArgumentError,
"#{klass} must be a subclass of " +
"AbtractContoller::Base. Instead, found " +
klass.ancestors.map(&:name).join(', ')
end
# ...
end
Ruby is Awesome
Ruby is now quite powerful in a lot of ways. Rails might be designed differently if it were created today, and it’s easy to get anchored into its way of doing things. When you are designing APIs, consider keyword arguments and consider using classes themselves to configure and set up parts of your app. Avoid using hashes as data structures when the structure itself is known and well-defined.