Two insights from using Sorbet

Lucian GhindaLucian Ghinda
4 min read

I am working with Sorbet on a relatively big Ruby on Rails project, and here are two quick learnings:

  1. It can help with simplifying code by removing unnecessary transformations.

  2. The support that Ruby has for doing things in multiple ways is very helpful when hitting a case where Sorbet has a rough edge

Let's take them step by step:

How to simplify methods by removing unnecessary transformations

Here is a piece of code that is a public interface of an object (this is not the actual code but an approximation of the actual code)

# typed: strict

ACCEPTED_KEYS = T.let(%i[name username email].freeze, T::Array[Symbol])

sig { params(attributes: T::Hash[T.any(Symbol, String), Integer]).returns(T::Hash[Symbol, Integer]) }  
def accepted_attributes(attributes)  
  attributes
      .symbolize_keys
      .slice(*ACCEPTED_KEYS)
end

Let's first focus on the .symbolize_keys call and the definition of the attributes param that is T::Hash[T.any(Symbol, String), Integer] allowing the method to be called with a hash with keys, symbols, or strings.

Notice that because I defined the attributes in that way (to accept both types of keys), I called symbolize_keys to transform them into symbols so that I could slice by ACCEPTED_KEYS, which are symbols.

But there is another way to think about this:

Because the method has type definitions, there is no need to support both String and Symbol as keys for the hash. The developer who plans to use this method can see exactly what kind of objects the keys should be.

So, the method can be transformed into a simpler one where I remove the symbolize_keys :

- sig { params(attributes: T::Hash[T.any(Symbol, String), Integer]).returns(T::Hash[Symbol, Integer]) }  
+ sig { params(attributes: T::Hash[Symbol, Integer]).returns(T::Hash[Symbol, Integer])} 
def accepted_attributes(attributes)  
  attributes
-      .symbolize_keys
      .slice(*ACCEPTED_KEYS)
end

An insight here is that Sorbet can help remove code that is there to handle the cases where the input is in various formats. Using types will provide hints to the user of the method about what kind of arguments are expected, so there is no need to handle multiple types.

Fixing the Sorbet splat arguments not well supported

Now, if you run the sorbet checker or play online on the sorbet playground, you will get an error that is something like this:

editor.rb:10: Splats are only supported where the size of the array 
                is known statically https://srb.help/7019
    10 |  attributes.slice(*ACCEPTED_KEYS)

And checking the Sorbet docs they say the following:

One solution would be to use T.unsafe but I don't want to do that unless there is no other way around.

After trying a couple of things, I finally decided to refactor it to:

sig { params(attributes: T::Hash[Symbol, Integer]).returns(T::Hash[Symbol, Integer]) }  
def accepted_attributes(attributes)  
  attributes.select { |key, _| ACCEPTED_KEYS.include?(key) }
end

And here comes the second insight:

The amazing thing about Ruby is that I can refactor that method from usingslice to using selectand still remain explicit and read like an English sentence.

A more elegant solution

Another solution (thank you Ufuk Kayserilioglu for sending it to me) to fix the warning in Sorbet will be to define the type of the constant as array where I specify the type for each element:

- ACCEPTED_KEYS = T.let(%i[name username email].freeze, T::Array[Symbol])
+ ACCEPTED_KEYS = T.let(%i[name username email].freeze, [Symbol, Symbol, Symbol])

and so have the final code look like this:

ACCEPTED_KEYS = T.let(%i[name username email].freeze, [Symbol, Symbol, Symbol])

sig { params(attributes: T::Hash[Symbol, Integer]).returns(T::Hash[Symbol, Integer])} 
def accepted_attributes(attributes)  
  attributes
      .slice(*ACCEPTED_KEYS)
end

Also with recent changes in Sorbet it now knows how to infer the type of the constant without the need to explicitely specify it so the final code would be very simpler like this:

ACCEPTED_KEYS = %i[name username email].freeze

sig { params(attributes: T::Hash[Symbol, Integer]).returns(T::Hash[Symbol, Integer]) }  
def accepted_attributes(attributes)  
  attributes.slice(*ACCEPTED_KEYS)
end

I think this is a much more elegant solution that makes the code simpler to read and keeps the original intention to use slice


Updates:

  • 17 August 2024 - Added a new solution to fix the splat operator error from Sorbet provided by Ufuk Kayserilioglu

  • 17 August 2024 - Updated the final example to use the latest Sorbet feature that knows to infer the type of the constants and so removes the need to use T.let


Enjoyed this article?

๐Ÿ‘ Subscribe to my Ruby and Ruby on rails courses over email at learn.shortruby.com- effortless learning anytime, anywhere

๐Ÿ‘‰ Join my Short Ruby News newsletter for weekly Ruby updates from the community and visit rubyandrails.info, a directory with learning content about Ruby.

๐Ÿค Let's connect on Ruby.social or Linkedin or Twitter where I post mainly about Ruby and Rails.

๐ŸŽฅ Follow me on my YouTube channel for short videos about Ruby

0
Subscribe to my newsletter

Read articles from Lucian Ghinda directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lucian Ghinda
Lucian Ghinda

Senior Product Engineer, working in Ruby and Rails. Passionate about idea generation, creativity, and programming. I curate the Short Ruby Newsletter.