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.