Sorbet as your Ruby mentor
Ruby has a number of ‘gotchas’ which often catch beginners off-guard. Although Sorbet is aimed at large, complex applications, it can also help to catch simple, common mistakes. And even in those large, complex apps, bugs can often be from simple mistakes. Let’s take a look at some.
Accidental use of a mutating method
Let’s write a method to return the capitalized version of a name:
def capitalize_name(name)
name.upcase!
end
The code incorrectly uses the upcase!
method instead of upcase
.
On first try, the method may appear to work. But there are problems lurking:
- If the name is unchanged, i.e. it’s already capitalized, it returns
nil
. This is a common behaviour for many of Ruby’s mutating methods. - More dangerously, it mutates the original name. That’s probably not what we want.
Let’s add a method signature:
sig { params(name: String).returns(String)}
def capitalize_name(name)
name.upcase!
end
When we typecheck this it fails:
Expected String but found T.nilable(String) for method result type
That’s a red flag that we’re unintentionally introducing a potential nil
return value.
By correcting upcase!
to upcase
, the typecheck passes.
Array index out of bounds
nil
values are a common source of bugs in Ruby code, since they can be inadvertently passed through many layers of a system, making it difficult to track down where they were first introduced. We can limit their damage by preventing a method from returning nil
.
Like many interpreted languages, Ruby will not complain if you try to reach beyond the end of an array:
colors = ['red', 'green', 'blue']
colors[3] #=> nil
In most cases, it would be better to fail right away if we access an out of range element.
Let’s add a signature to say this can’t return nil
:
sig { params(values: T::Array[String], position: Integer).returns(String)}
def value_at_position(values, position)
values[position]
end
This fails to typecheck with:
Expected String but found T.nilable(String) for method result type
We can avoid this by using fetch
on the array, which will raise an IndexError
if the index is out of bounds.
(fetch
is more commonly used for hashes, but is also a useful technique when working with arrays).
sig { params(values: T::Array[String], position: Integer).returns(String)}
def value_at_position(values, position)
values.fetch(position)
end
Since the method is now guaranteed to return a string, the typecheck passes.
Confusing symbolic and string hash keys
Ruby has several ways to represent keys in a hash literal, which can be confusing:
colors = { "red" => "FF0000", green: "00FF00", "blue": "0000FF" }
The first two are fairly obvious: red
is a string key, and green
is a symbol key. The third is tricky, especially if you’re used to reading JavaScript, since it’s also a symbol key.
Let’s write a method which grabs the value out of a hash.
def get_hex_value_for_color(colors, name)
colors.fetch(name)
end
We can add a Sorbet signature to only allow strings as keys:
sig { params(colors: T::Hash[String, String], name: String).returns(String) }
def get_hex_value_for_color(colors, name)
colors.fetch(name)
end
Now if we try to pass a hash with symbol keys, the typecheck will fail.
Summing up
Hopefully this gives you a taste of some of the common mistakes that Sorbet can catch. Some of these can be caught immediately in your IDE. Others would only be caught at runtime. But in both cases they should give useful, actionable feedback to help you write better code.
For more examples of using Sorbet on small programs, you can check out my colleague Emily’s solutions to Advent Of Code using Sorbet: https://github.com/egiurleo/advent-of-code-2022.