Sorbetting a gem, or the story of the first adoption
Tags: ruby, statictyping, sorbet
Published: 2019-06-24 13:50:16

---
title: Sorbetting a gem, or the story of the first adoption
published: true
canonical_url: https://evilmartians.com/chronicles/sorbetting-a-gem
description: The story of integrating Sorbet type checker into a Ruby gem development
cover_image: https://thepracticaldev.s3.amazonaws.com/i/viviqihehhobuf2lbvae.png
tags: ruby, statictyping, sorbet
---

**NOTE**: _this post was written a few days after the first public Sorbet release (on 2019-06-20). If you're reading this months after that happened, most of the things described here could be no longer relevant._

Stripe has finally open-sourced their long-awaiting static type checker for Ruby—[Sorbet](https://sorbet.org). I've been waiting for this for more than a year since it was [first announced](https://twitter.com/darkdimius/status/1002049138366730240).

To be honest, I'm not a big fan of type annotations, or more precisely, I'm on the "I hate type annotations" side.

{% twitter 1141981078984548353 %}

But as a member of Ruby community, I'm glad to see something like this happening: this is a **huge leap forward for Ruby** itself (especially, compared to other _evolutionary features_ like [~~pipeline~~ operator](https://twitter.com/a_matsuda/status/1139110957450375168)). Stripe team did a great job :+1:!

After reading about Brandon's [first impression](https://dev.to/baweaver/a-scoop-of-sorbet-first-impressions-f30) (highly recommend to check it out), I decided to give Sorbet a try and integrate it into one of my gems.

> **tl;dr** type checker works great, but not ideal; annotations are ugly; tooling leaves much to be desired.

## [Rubanok](https://github.com/palkan/rubanok) meets Sorbet

I decided to use [`rubanok`](https://github.com/palkan/rubanok) for this experiment: it's the simplest gem of mine in terms of the amount of code and metaprogramming.

> You can find the _adoption_ PR here: https://github.com/palkan/rubanok/pull/5.

### `bundle exec srb init`

The first phase of the adoption is to add `sorbet` gem to the bundle and generate all the required files (read more and see the screenshots of this process [here](https://dev.to/baweaver/a-scoop-of-sorbet-first-impressions-f30)).

Unfortunately, it didn't go so well:

```sh
$ bundle exec srb init

👋 Hey there!

...

Traceback (most recent call last):
    15: from /ruby/gems/2.6.0/gems/sorbet-0.4.4280/bin/srb-rbi:232:in `<main>'
         ...
     3: from /ruby/gems/2.6.0/gems/sorbet-0.4.4280/lib/gem_loader.rb:553:in `require'
     2: from /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:4:in `<top (required)>'
     1: from /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:6:in `<module:RSpec>' /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:8:in `<module:Rails>':
     uninitialized constant Rails (NameError)
```

RSpec Rails refers to `Rails` constant in its code but doesn't require `rails` anywhere in the code (it should be somewhere [here](https://github.com/rspec/rspec-rails/blob/master/lib/rspec-rails.rb)).

As far as I understand, Sorbet _evaluates_ each file for building `.rbi` configs for your bundle and it doesn't know how to handle such exceptions.

That's an edge case that should be solved at the `rspec-rails` side (would you like to open a PR?).

There is a [similar open issue](https://github.com/sorbet/sorbet/issues/925) regarding optional dependencies as well.

Adding `rails` as a dev dependency helped to solve this issue (honestly, don't why 🤷‍♂️).

After that, a new folder, `sorbet/`, appeared in the project directory with a bunch of [`.rbi` files](https://sorbet.org/docs/rbi):

```sh
$ find sorbet -type f | wc -l
      55
```

As the [documentation suggests](https://sorbet.org/docs/rbi#a-note-about-vendoring-rbis), you should put this directory into your version control system. Even though type signatures are not so _heavy_ (as `node_modules/`, for example), I hope this [will change in the future](https://sorbet.org/docs/rbi#rbis-within-gems). The _raw_ size (w/o `.git/`) of `rubanok` increased by 2.1MB, from 124KB to ~2.2MB.

### `bundle exec srb tc`

When you first run the type checking command, you should not see any errors:

```sh
$ bundle exec srb tc
No errors! Great job.
```

That's because Sorbet adds the `# typed: false` magic comment to all the source files by default. This [strictness level](https://sorbet.org/docs/static#file-level-granularity-strictness-levels) only checks for critical problems (constants, syntax, invalid signatures).

[Gradual type checking](https://sorbet.org/docs/gradual#what-is-a-gradual-type-system) implies that you make your source code type-aware step-by-step by changing `# typed: false` to `# typed: true` (or even `# typed: strong`).

### Problem #1. Unsupported features.

I started by enabling type checks for the core Rubanok class—[Rule](https://github.com/palkan/rubanok/blob/v0.1.3/lib/rubanok/rule.rb):

```sh
$ be srb tc
lib/rubanok/rule.rb:62: Method Rubanok::Rule#empty? redefined without matching argument count. Expected: 0, got: 1 https://srb.help/4010
    62 |    def empty?(val)
            ^^^^^^^^^^^^^^^
    lib/rubanok/rule.rb:56: Previous definition
    56 |        def empty?
```

That's strange; I don't have duplicate method definitions, RuboCop could catch this easily. What made Sorbet think so? This:

```ruby
using(Module.new do
  refine NilClass do
    def empty?
      true
    end
  end

  refine Object do
    def empty?
      false
    end
  end
end)

def empty?(val)
  return false unless Rubanok.ignore_empty_values

  val.empty?
end
```

Looks like Sorbet doesn't recognize anonymous modules and treats their contents as the "parent" module contents. Hopefully, it seems to be an easy fix and a good first contribution:

{% github https://github.com/sorbet/sorbet/issues/1010#issuecomment-504709824 %}

Note that this problem has nothing with refinements themselves (although I think they are not supported at all and will not be anytime soon).

I quickly fixed this by moving the refinement outside of the Rule class. ~~But at what cost?~~ That made it less clear why and where we need this refinement. The code became a bit more entangled. And that's just the beginning...

### Problem #2. [Limitations of flow-sensitivity](https://sorbet.org/docs/flow-sensitive#limitations-of-flow-sensitivity)

After toggling `# typed: true` for another class, [Plane](https://github.com/palkan/rubanok/blob/v0.1.3/lib/rubanok/plane.rb), I found another interesting case:

```sh
$ bundle exec srb tc
lib/rubanok/plane.rb:50: Method <= does not exist on NilClass component of T.nilable(Class) https://srb.help/7003
    50 |          if superclass <= Plane
                     ^^^^^^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    lib/rubanok/plane.rb:50: Replace with T.must(superclass)
    50 |          if superclass <= Plane
                     ^^^^^^^^^^

lib/rubanok/plane.rb:51: Method rules does not exist on Class component of T.nilable(Class) https://srb.help/7003
    51 |            superclass.rules.dup
                    ^^^^^^^^^^^^^^^^

lib/rubanok/plane.rb:51: Method rules does not exist on NilClass component of T.nilable(Class) https://srb.help/7003
    51 |            superclass.rules.dup
                    ^^^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    lib/rubanok/plane.rb:51: Replace with T.must(superclass)
    51 |            superclass.rules.dup
                    ^^^^^^^^^^
```

The "violating" code:

```ruby
def rules
  return @rules if instance_variable_defined?(:@rules)

  @rules =
    if superclass <= Plane
      superclass.rules.dup
    else
      []
    end
end
```

Is this a bug? Don't think so: as far as I know, the only case when `superclass` returns `nil` is `BasicObject`. Or if we redefine the `.superclass` method 😜

The suggested solution—using `T.must(superclass)`—is not a good fit: I don't want my code to have dirty hacks only to satisfy the type system.

I've tried to make Sorbet happy the other way—by _unwrapping_ the `superclass` value:

```ruby
def rules
  return @rules if instance_variable_defined?(:@rules)

  @rules =
    if superclass && superclass <= Plane
      superclass.rules.dup
    else
      []
    end
end
```

That didn't have any effect—still the same errors. I've tried again:

```ruby
def rules
  return @rules if instance_variable_defined?(:@rules)

  @rules =
    if superclass
      if superclass <= Plane
        superclass.rules.dup
      else
        []
      end
    else
      []
    end
end
```

Still the same :( The last attempt (I thought):

```ruby
def rules
  return @rules if instance_variable_defined?(:@rules)

  x = superclass
  @rules =
    if x
      if x <= Plane
        x.rules.dup
      else
        []
      end
    else
      []
    end
end
```

Almost works:

```sh
$ bundle exec srb tc
lib/rubanok/plane.rb:53: Method rules does not exist on Class https://srb.help/7003
    53 |              x.rules.dup
                      ^^^^^^^
Errors: 1
```

But why it cannot infer the superclass class from the `x <= Plane` check?
If you check [the complete list](https://sorbet.org/docs/flow-sensitive#limitations-of-flow-sensitivity) of constructs that affect Sorbet’s flow-sensitive typing, you can find that only `Class#<` is supported but not `Class#<<` 🤷‍♂️

OK. Let's replace `x <= Plane` with `x < Plane` (this is actually a breaking change: someone could define global rules on `Rubanok::Plane` class itself, which is not a good idea but...).

### Problem #3. Signatures vs modules.

Adding signatures for Rule and Plane went pretty smooth (LOC increased from 159 to 196). And I didn't have to change any code.

Then I turned on type checking for DSL modules, [Mapping](https://github.com/palkan/rubanok/blob/v0.1.3/lib/rubanok/dsl/mapping.rb) and [Matching](https://github.com/palkan/rubanok/blob/v0.1.3/lib/rubanok/dsl/matching.rb).

These modules implement the particular Rubanok transformations and extend the `Rubanok::Plane` class.

The first problem occurred with a pretty standard Ruby code. Here is the simplified example:

```ruby
class Rule
  sig do
      params(
        fields: T::Array[Symbol],
        activate_on: T::Array[Symbol]
      ).void
    end
  def initialize(fields, activate_on: fields)
    # ...
  end
end

module Mapping
  def map(*fields, **options, &block)
    rule = Rule.new(fields, options)
  end
end
```

This code raises the following type error:

```sh
$ be srb tc
lib/rubanok/dsl/mapping.rb:25: Passing a hash where the specific keys are unknown to a method taking keyword arguments https://srb.help/7019
    25 |        rule = Rule.new(fields, options)
                       ^^^^^^^^^^^^^^^^^^^^^^^^^
```

Seems legit: do not allow passing an arbitrary hash as the known keyword arguments.

Let's try to add a signature for the `#map` method using [shapes](https://sorbet.org/docs/shapes):

```ruby
sig do
  params(
    fields: Symbol,
    options: {
      activate_on: T::Array[Symbol]
    },
    block:  T.proc.void
  ).returns(Rule)
end
def map(*fields, **options, &block)
  rule = Rule.new(fields, options)
end
```

(Expectedly) didn't help:

```sh
$ bundle exec srb tc
./lib/rubanok/mapping.rb:34: Passing a hash where the specific keys are unknown to a method taking keyword arguments https://srb.help/7019
    34 |    rule = Rule.new(fields, options)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
  Got T::Hash[Symbol, {activate_on: T::Array[Symbol]}] originating from:
    ./lib/rubanok/mapping.rb:33:
    33 |  def map(*fields, **options, &block)
                           ^^^^^^^^^
```

This `Got T::Hash[Symbol, {activate_on: T::Array[Symbol]}]` looks suspicious. Where did it found a hash with Symbol keys? No idea.

I gave up and duplicated the keywords for the `#map` method:

```ruby
sig do
  params(
    fields: Symbol,
    activate_on: T::Array[Symbol],
    block: T.proc.void
  )
  .returns(T::Array[Rubanok::Rule])
end
def map(*fields, activate_on: fields, &block)
  rule = Rule.new(fields, activate_on: activate_on)
  # ...
end
```

This doesn't seem right to me: now I need to think about keeping these signatures in sync in three different places (the type checker will definitely help here), there is a chance that I will lose this very crucial `activate_on: fields` default value (the type checker cannot help here).

If you know how to add signatures without changing the code itself—please, leave a comment!

The second problem with modules relates to the fact that they are only meant for extending the `Rubanok::Plane` class; thus they "know" a few things about the Plane API and use it. For example, they use `#rules` method:

```ruby
def map(*fields, activate_on: fields, &block)
  rule = Rule.new(fields, activate_on: activate_on)
  # ...
  rules << rule
end
```

Sorbet has no idea of our _intent_; hence, it reports this error:

```sh
lib/rubanok/dsl/mapping.rb:38: Method rules does not exist on Rubanok::DSL::Mapping https://srb.help/7003
    38 |        rules << rule
```

I couldn't find anything similar to this situation in the docs, only the section devoted to [interfaces](https://sorbet.org/docs/abstract) turned out to be useful: I marked the module as `abstract!` and defined an _abstract_ `#rules` method:

```ruby
sig { abstract.returns(T::Array[Rubanok::Rule]) }
def rules
end
```

It made this error disappeared. Bonus: see what happens if you remove or rename the `Plane.rules` method:

```sh
$ bundle exec srb tc
lib/rubanok/plane.rb:36: Missing definition for abstract method Rubanok::DSL::Mapping#rules https://srb.help/5023
    36 |    class << self
            ^^^^^
    lib/rubanok/dsl/mapping.rb:47: defined here
    47 |      def rules
              ^^^^^^^^^
```

### Problem #4. Metaprogramming.

Metaprogramming is what makes Ruby such a powerful language (and makes me love Ruby).

> Ruby without metaprogramming is not Ruby.

On the other hand, this is one of the things that makes static type checking so tricky. I don't expect a type checker to be so smart and know how to deal with any meta stuff; I only need from it a _canonical_ way to handle the situations like the one described below.

The `#match` method provided by [Matching]() module generates dynamic methods, which rely on a couple of Plane instance methods:

```ruby
define_method(rule.to_method_name) do |params = {}|
  clause = rule.matching_clause(params)
  next raw unless clause

  apply_rule! clause.to_method_name, clause.project(params)
end
```

Sorbet didn't like it:

```sh
$ bundle exec srb tc
lib/rubanok/dsl/matching.rb:106: Method raw does not exist on Rubanok::DSL::Matching https://srb.help/7003
     106 |          next raw unless clause
                         ^^^

lib/rubanok/dsl/matching.rb:108: Method apply_rule! does not exist on Rubanok::DSL::Matching https://srb.help/7003
     108 |          apply_rule! clause.to_method_name, clause.project(params)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Errors: 3
```

The trick with adding abstract methods didn't work out (since we need to add instance methods, not singleton ones).

Re-generating [hidden definitions](https://sorbet.org/docs/rbi#the-hidden-definition-rbi)\* didn't work either.

I didn't find anything better than adding a custom RBI file to the repo:

```ruby
# sorbet/meta.rbi

# typed: true

module Rubanok::DSL::Matching
  sig { returns(T.untyped) }
  def raw
  end

  sig { params(method_name: String, data: T.untyped).returns(T.untyped) }
  def apply_rule!(method_name, data)
  end
end
```

Yet another hack. "Enough," I thought, and didn't even try to enable type checking for [Rails controller integration](https://github.com/palkan/rubanok/blob/v0.1.3/lib/rubanok/rails/controller.rb) concern.

\* I tried to check how hidden definitions work with a more straightforward example:

```ruby
# typed: true

class A
  def x
    "y"
  end

  define_method(:xx) do
    x * 2
  end
end
```

After running `bundle exec srb rbi hidden-definitions` I found the following line in `sorbet/hidden-definitions/hidden.rbi`:

```ruby
class A
  def xx(); end
end
```

So, Sorbet found this `define_method`. And, for some reason, it also changed `# typed: true` to `# typed: false`. After turning it back, I got:

```sh
$ bundle exec srb tc

lib/rubanok/a.rb:9: Method x does not exist on T.class_of(A) https://srb.help/7003
     9 |    x * 2
            ^
    lib/rubanok/a.rb:4: Did you mean: A#x?
     4 |  def x
          ^^^^^
Errors: 1
```

As we see from the error message, Sorbet treats `#xx` as a class method. There is a relevant open issue: [#64](https://github.com/sorbet/sorbet/issues/64).

### Problem #5. Runtime checks.

So far, I only tried to make static checks pass but haven't tried to run the code:

```sh
$ bundle exec rspec

NameError:
  uninitialized constant Rubanok::Rule::T 
```

Right, we have signatures in our code, which we haven't loaded yet.

I added the `require "sorbet-static"` line to the main project file. And I was a bit surprised:

```sh
$ bundle exec rspec

LoadError:
  cannot load such file -- sorbet-static
```

My bad: I assumed that you could use Sorbet without runtime checks and that's what [`sorbet-static` gem](https://github.com/sorbet/sorbet/tree/v0.4.4215.20190617153517-1dd6f9bcd/gems/sorbet-static) is for.

> As it turned out, there is no way to avoid `sorbet-runtime` if you have signatures in your code.

I started to hate type annotations more: I don't want to add an additional dependency to the gem, even if the overhead of type checking is <10% (which is still more than 0%):

{% twitter 1141815076615147520 %}

OK. Let's play this game 'till the end.

After adding `sorbet-runtime` I was able to run the code and even caught one "problem":

```sh
$ bundle exec rspec

Failure/Error: rules << rule

RuntimeError:
  You must use `.implementation` when overriding the abstract method `rules`.
    Abstract definition: Rubanok::DSL::Matching at /Users/palkan/dev/rubanok/lib/rubanok/dsl/matching.rb:119
    Implementation definition: #<Class:Rubanok::Plane> at /Users/palkan/dev/rubanok/lib/rubanok/plane.rb:53
```

Why didn't static analysis caught this?

**UPD:** it's possible to "disable" runtime checks by using [`T::Sig::WithoutRuntime.sig`](https://github.com/sorbet/sorbet/blob/ac53390a9a17e1563e815875abe74ef6a0342385/gems/sorbet-runtime/lib/types/sig.rb#L8-L17) instead of `sig` to write signatures. And it's not possible to write `extend T::Sig::WithoutRuntime` instead of `extend T::Sig` 😄

### Problem #6. Debugging.

I'm a heavy user of [`binding.pry`](https://github.com/deivid-rodriguez/pry-byebug).

When I was debugging the code with type signatures, I found it's very hard to _step into_ the method:

![](https://thepracticaldev.s3.amazonaws.com/i/bqnuakatobw18sip8dz9.gif)

Can you find where is the original method hiding?


## In conclusion, or it's just the beginning

Sorbet is one of the most important things that happened to Ruby in the last few years.

But it's still far from bringing the development happiness. And it's you who can help it become better: give it a try, report [issues](https://github.com/sorbet/sorbet/issues) or say "Thanks!" to people behind it (e.g., [Dmitry Petrashko](https://twitter.com/darkdimius) and [Paul Tarjan](https://twitter.com/ptarjan)).

Will I use Sorbet?

Likely, yes. But only in the way described below.

After adding all the signatures to the codebase (_full adoption_), **I want to be able to dump them into a `.rbi` file** and clean up the codebase. Thus, my Ruby code stays the same: less verbose and more readable.

That should break neither static nor runtime checks, i.e., if `sorbet-runtime` is loaded, runtime checks are activated, otherwise—not. Static checks should work just because of the presence of the RBI files.

**P.S.** I'm only talking about libraries development right now. 

**P.P.S.** I want to try to _adopt_ [Steep](https://github.com/soutaro/steep) as well and compare the process to the one described above. We'll see which one I like more.

---

Read more dev articles on [https://evilmartians.com/chronicles](https://evilmartians.com/chronicles)!