sorbet-rails

Gem Version Build Status codecov

A set of tools to make the Sorbet typechecker work with Ruby on Rails seamlessly.

This gem adds a few Rake tasks to generate Ruby Interface (RBI) files for dynamic methods generated by Rails. It also includes signatures for related Rails classes. The RBI files are added to a sorbet/rails-rbi/ folder.

sorbet-rails supports Rails 4.2 or later.

Initial Setup

  1. Follow the steps here to set up the latest version of Sorbet, up to being able to run srb tc.

  2. Add sorbet-rails to your Gemfile and install them with Bundler.

# -- Gemfile --

gem 'sorbet-rails'
❯ bundle install
  1. Generate RBI files for your routes, models, and helpers: ```sh ❯ rake rails_rbi:routes ❯ rake rails_rbi:models ❯ rake rails_rbi:helpers

or run them all at once

❯ rake rails_rbi:all


4. Update hidden-definition files and automatically upgrade each file's typecheck level:
```sh
❯ srb rbi hidden-definitions
❯ srb rbi suggest-typed

Because we've generated RBI files for routes, models, and helpers, a lot more files should be typecheckable now. Many methods in hidden.rbi may be removed because they are now typed.

RBI Files

ActiveRecord

There is an ActiveRecord RBI file that we vendor with this gem. Sorbet picks up these vendored RBI files automatically. (Please make sure you are running the latest version.)

Routes

This Rake task generates an RBI file defining _path and _url methods for all named routes in routes.rb:

❯ rake rails_rbi:routes

Models

This Rake task generates RBI files for all models in the Rails application (all descendants of ActiveRecord::Base):

❯ rake rails_rbi:models

You can also regenerate RBI files for specific models:

❯ rake rails_rbi:models[ModelName,AnotherOne,...]

The generation task currently creates the following signatures:

  • Column getters & setters
  • Associations getters & setters
  • Enum values, checkers & scopes
  • Named scopes
  • Model relation class

It is possible to add custom RBI generation logic for your custom module or gems via the plugin system. Check out the plugins section below if you are interested.

Helpers

This Rake task generates a helpers.rbi file that includes a basic module definition which includes the Kernel module, to allow for some basic Ruby methods to be used in helpers without Sorbet complaining.

❯ rake rails_rbi:helpers

Tips & Tricks

Overriding generated signatures

sorbet-rails relies on Rails reflection to generate signatures. There are features this gem doesn't support yet such as serialize and attribute custom types. The gem also doesn't know the signature of any methods you have overridden. However, it is possible to override the signatures that sorbet-rails generates.

For example, here is how to override the signature for a method in a model:

# -- app/models/model_name.rbi --

# typed: strong
class ModelName
  sig { returns(T::Hash[...]) }
  def field_name; end

  sig { params(obj: T::Hash[....]).void }
  def field_name=(obj); end
end

find, first and last

These 3 methods can either return a single nilable record or an array of records. Sorbet does not allow us to define multiple signatures for a function (except stdlib). It doesn't support defining one function signature that has varying returning value depending on the input parameter type. We opt to define the most commonly used signature for these methods, and monkey-patch new functions for the secondary use case.

In short:

  • Use find, first and last to fetch a single record.
  • Use find_n, first_n, last_n to fetch an array of records.

find_by_<attributes>, <attribute>_changed?, etc.

Rails supports dynamic methods based on attribute names, such as find_by_<attribute>, <attribute>_changed?, etc. They all have static counterparts. Instead of generating all possible dynamic methods that Rails support, we recommend to use of the static version of these methods instead (also recommended by RuboCop).

Following are the list of attribute dynamic methods and their static counterparts. The static methods have proper signatures:

  • find_by_<attributes> -> find_by(<attributes>)
  • find_by_<attributes>! -> find_by!(<attributes>)
  • <attribute>_changed? -> attribute_changed?(<attribute>)
  • saved_change_to_<attribute>? -> saved_change_to_attribute?(<attribute>)

after_commit and other callbacks

Consider converting after_commit callbacks to use instance method functions. Sorbet doesn't support binding an optional block with a different context. Because of this, when using a callback with a custom block, the block is evaluated in the wrong context (Class-level context). Refer to this page for a full list of callbacks available in Rails.

Before:

after_commit do ... end

After:

after_commit :after_commit
def after_commit
  ...
end

If you wanted to make these changes using Codemod, try these commands:

# from methods like after_commit do <...> end
❯ codemod -d app/models/ --extensions rb \
  '(\s*)(before|after)_(validation|save|create|commit|find|initialize|destroy) do' \
  '\1\2_\3 :\2_\3\n\1def \2_\3'

# from methods like after_commit { <...> }
❯ codemod -d app/models/ --extensions rb \
  '(\s*)(before|after)_(validation|save|create|commit|find|initialize|destroy) \{ (.*) \}' \
  '\1\2_\3 :\2_\3\n\1def \2_\3\n\1\1\4\n\1end'

Note that Codemod's preview may show that the indentation is off, but it works.

Look for # typed: ignore files

Because Sorbet's initial setup tries to flag files at whichever typecheck level generates 0 errors, there may be files in your repository that are # typed: ignore. This is because sometimes Rails allows very dynamic code that Sorbet does not believe it can typecheck.

It is worth going through the list of files that is ignored and resolve them (and auto upgrade the types of other files; see initial setup above). Usually this will make many more files able to be typechecked.

unscoped with a block

The unscoped method returns a Relation when no block is provided. When a block is provided, unscoped calls the block and returns its result, which could be any type.

sorbet-rails chooses to define unscoped as returning a Relation because it's more common and more useful. If you want to use a block, either override the unscoped definition, or replace:

Model.unscoped do  end

with:

Model.unscoped.scoping do  end

Extending Model Generation Task with Custom Plugins

sorbet-rails support a customizable plugin system that you can use to generate additional RBI for each model. This will be useful to generate RBI for methods dynamically added by gems or private concerns. If you write plugins for public gems, please feel free to contribute it to this repo.

Defining a Custom ModelPlugin

A custom plugin should be a subclass of SorbetRails::ModelPlugins::Base. Each plugin would implement a generate(root) method that generate additional rbi for the model.

At a high level, here is the structure of a plugin:

# -- lib/my_custom_plugin.rb
class MyCustomPlugin < SorbetRails::ModelPlugins::Base
  sig { implementation.params(root: Parlour::RbiGenerator::Namespace).void }
  def generate(root)
    # TODO: implement the generation logic
    # You can use @model_class and @available_classes here
  end
end

During the generation phase, the system will create a new instance of the plugin, with the model_class to be generated and a set of all available_classes (available models) detected in the Rails App.

We use Parlour gem to generate the RBI for a model. Please check out Parlour wiki for guide to add RBI, eg create a new module, class, or method in the generated file.

At a high level, you'd usually want to create a model-scoped module for your methods, and include or extend it in the base model class. The generation logic usually looks like this:

  def generate(root)
    # Make sure this is only run for relevant class
    return unless @model_class.include?(CustomModule)

    custom_module_name = self.model_module_name("CustomModule")
    custom_module_rbi = root.create_module(custom_module_name)

    # here we re-create the model class!
    model_class_rbi = root.create_class(self.model_class_name)
    model_class_rbi.create_extend(custom_module_name)

    # then create custom methods, constants, etc. for this module.
    custom_module_rbi.create_method(...)

    # this is allowed but not recommended, because it limit the ability to override the method.
    model_class_rbi.create_method(...)
  end

Notice that we re-create model_class_rbi here. Parlour's ConflictResolver will merge the classes or modules with the same name together to generate 1 beautiful RBI file. It'll also flag and skip if any method is created multiple times with conflict signatures. Check-out useful predefined module names & helper methods in model_utils.

It is also allowed to put methods into a model class directly. However, it is not recommended because it'll be harder to override the method. sorbet will enforce that the overriding method match the signature generated. It also makes the generated RBI file less modularized.

However, sometimes this is required to make sorbet recognize the signature. This is the case for class methods added by ActiveRecord::Concerns. Because ActiveSupport::Concern class methods will be inserted to the class directly, you need to also put the sig in the model class rbi directly.

It is also recommended to check if the generated methods are detected by sorbet as a gem method (in sorbet/rbi/gem/gem_name.rbi) or hidden methods (in sorbet/rbi/hidden-definitions/hidden.rbi). If so, you may need to re-run srb rbi hidden-definition or put method in the model class directly.

Check out the plugins written for sorbet-rails's own model RBI generation logic for examples.

Registering new plugins

You can register your plugins in an initializer:

# -- config/initializers/sorbet_rails.rb
SorbetRails::ModelRbiFormatter.register_plugin(MyCustomPlugin)

The default plugins and other customizations are defined here.

Contributing

Contributions and ideas are welcome! Please see our contributing guide and don't hesitate to open an issue or send a pull request to improve the functionality of this gem.

This project adheres to the Contributor Covenant code of conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to [email protected].

License

MIT