Climbing Steep hills, or adopting Ruby 3 types
Tags: ruby, types, steep
Published: 2020-12-11 07:53:55

With Ruby 3.0 just around the corner, let's take a look at one of the highlights of the upcoming release: [Ruby Type Signatures][rbs]. Yes, types come to our favourite dynamic language—let's see what could work out of that!

It is not the first time I'm writing about types for Ruby: more than a year ago, I _tasted_ [Sorbet][] and shared my experience in the [Martian Chronicles][sorbet-post]. At the end of the post, I promised to give another Ruby type checker a try: [Steep][]. So, here I am, paying my debts!

> I'd highly recommend taking a look at the ["Sorbetting a gem" post][sorbet-post] first since I will refer to it multiple times today.

## RBS in a nutshell

RBS is a language to describe _the structure of Ruby programs_ (from [Readme][rbs]). The "structure" includes class and method signatures, type definitions, etc.

Since it's a separate language, not Ruby, separate `.rbs` files are used to store typings.

Let's jump right into an example:

```ruby
# martian.rb
class Martian < Alien
  def initialize(name, evil: false)
    super(name)
    @evil = evil
  end

  def evil?
    @evil
  end
end

# martian.rbs
class Alien
  attr_reader name : String
  
  def initialize : (name: String) -> void
end

class Martian < Alien
  @evil : bool

  def initialize : (name: String, ?evil: bool) -> void
  def evil? : () -> bool
end
```

The signature looks pretty similar to the class definition itself, except that we have types specified for arguments, methods, and instance variables. So far, looks pretty Ruby-ish. However, RBS has some entities which are missing in Ruby, for example, _interfaces_. We're gonna see some examples later.

RBS itself doesn't provide any functionality to perform type checking\*; it's just a language, remember? That's where Steep comes into a stage.

\* Actually, that's not 100% true; there is runtime type checking mode. Continue reading to learn more.

In the rest of the article, I will describe the process of adding RBS and Steep to [Rubanok][] (the same project as I used in the Sorbet example, though the more recent version).

## Getting started with RBS

It could be hard to figure out how to start adding types to an existing project. Hopefully, RBS provides a way to generate a types scaffold for your code.

RBS comes with a CLI tool (`rbs`) which has a bunch of commands, but we're interested only in the `prototype`:

```sh
$ rbs prototype -h
Usage: rbs prototype [generator...] [args...]

Generate prototype of RBS files.
Supported generators are rb, rbi, runtime.

Examples:

  $ rbs prototype rb foo.rb
  $ rbs prototype rbi foo.rbi
  $ rbs prototype runtime String
```

The description is pretty self-explanatory; let's try it:

```sh
$ rbs prototype rb lib/**/*.rb

# Rubanok provides a DSL ... (all the comments from the source file) 
module Rubanok
  attr_accessor ignore_empty_values: untyped
  attr_accessor fail_when_no_matches: untyped
end

module Rubanok
  class Rule
    # :nodoc:
    UNDEFINED: untyped

    attr_reader fields: untyped
    attr_reader activate_on: untyped
    attr_reader activate_always: untyped
    attr_reader ignore_empty_values: untyped
    attr_reader filter_with: untyped

    def initialize: (untyped fields, ?activate_on: untyped activate_on, ?activate_always: bool activate_always, ?ignore_empty_values: untyped ignore_empty_values, ?filter_with: untyped? filter_with) -> untyped
    def project: (untyped params) -> untyped
    def applicable?: (untyped params) -> (::TrueClass | untyped)
    def to_method_name: () -> untyped

    private

    def build_method_name: () -> ::String
    def fetch_value: (untyped params, untyped field) -> untyped
    def empty?: (untyped val) -> (::FalseClass | untyped)
  end
end

# <truncated>
```

The first option (`prototype rb`) generates a signature for all the entities specified in the file (or files) you pass using static analysis (more precisely, via parsing the source code and analyzing ASTs).

This command _streams_ to the standard output all the found typings. To save the output, one can use redirection:

```sh
rbs prototype rb lib/**/*.rb > sig/rubanok.rbs
```

I'd prefer to mirror signature files to source files (i.e., have multiple files). We can achieve this with some knowledge of Unix:

```sh
find lib -name \*.rb -print | cut -sd / -f 2- | xargs -I{} bash -c 'export file={}; export target=sig/$file; mkdir -p ${target%/*}; rbs prototype rb lib/$file > sig/${file/rb/rbs}'
```

In my opinion, it would be much better if we had the above functionality by default (or maybe that's a feature—keeping all the signatures in the same file 🤔). 

Also, copying comments from source files to signatures makes the latter less readable (especially if there are many comments, like in my case). Of course, we can add a bit more Unix magic to fix this...

Let's try runtime mode:

```sh
$ RUBYOPT="-Ilib" rbs prototype runtime -r rubanok Rubanok::Rule

class Rubanok::Rule
  public

  def activate_always: () -> untyped
  def activate_on: () -> untyped
  def applicable?: (untyped params) -> untyped
  def fields: () -> untyped
  def filter_with: () -> untyped
  def ignore_empty_values: () -> untyped
  def project: (untyped params) -> untyped
  def to_method_name: () -> untyped

  private

  def build_method_name: () -> untyped
  def empty?: (untyped val) -> untyped
  def fetch_value: (untyped params, untyped field) -> untyped
  def initialize: (untyped fields, ?activate_on: untyped, ?activate_always: untyped, ?ignore_empty_values: untyped, ?filter_with: untyped) -> untyped
end
```

In this mode, RBS uses Ruby introspection APIs (`Class.methods`, etc.) to generate the specified class or module signature.

Let's compare signatures for the `Rubanok::Rule` class generated with `rb` and `runtime` modes:

- First, runtime generator does not recognize `attr_reader` (for instance, `activate_on` and `activate_always`).
- Second, runtime generator sorts methods alphabetically while static generator preserves the original layout.
- Finally, the first signature has a few types defined, while the latter has everything `untyped`.

So, why one may find runtime generator useful? I guess there are only one reason for that: **dynamically generated methods**. Like, for example, in [Active Record][active-record-method-gen].


Thus, both modes have their advantages and disadvantages and using them both would provide a better _signature coverage_. Unfortunately, there is no good way to diff/merge RBS files yet; you have to that manually. Another manual work is to replace `untyped` with the actual typing information.

But wait to make your hands dirty. There is one more player in this game–[Type Profiler][typeprof].

Type Profiler infers a program type signatures dynamically during the execution. It _spies_ all the loaded classes and methods and collects the information about which types have been used as inputs and outputs, analyzes this data, and produces RBS definitions. Under the hood, it uses a custom Ruby interpreter (so, the code is not actually executed). You can find more in the [official docs][typeprof-doc].

The main difference between TypeProf and RBS is that we need to create a sample script to be used as a profiling entry-point.

Let's write one:

```ruby
# sig/rubanok_type_profile.rb
require "rubanok"

processor = Class.new(Rubanok::Processor) do
  map :q do |q:|
    raw
  end

  match :sort_by, :sort, activate_on: :sort_by do
    having "status", "asc" do
      raw
    end

    default do |sort_by:, sort: "asc"|
      raw
    end
  end
end

processor.project({q: "search", sort_by: "name"})
processor.call([], {q: "search", sort_by: "name"})
```

Now, let's run `typeprof` command:

```sh
$ typeprof -Ilib sig/rubanok_type_profile.rb --exclude-dir lib/rubanok/rails --exclude-dir lib/rubanok/rspec.rb

# Classes
module Rubanok
  VERSION : String

  class Rule
    UNDEFINED : Object
    @method_name : String
    attr_reader fields : untyped
    attr_reader activate_on : Array[untyped]
    attr_reader activate_always : false
    attr_reader ignore_empty_values : untyped
    attr_reader filter_with : nil
    def initialize : (untyped, ?activate_on: untyped, ?activate_always: false, ?ignore_empty_values: untyped, ?filter_with: nil) -> nil
    def project : (untyped) -> untyped
    def applicable? : (untyped) -> bool
    def to_method_name : -> String
    private
    def build_method_name : -> String
    def fetch_value : (untyped, untyped) -> Object?
    def empty? : (nil) -> false
  end

  # ...
end
```

Nice, now we have some types defined (though most of them are still untyped), we can see methods visibility and even instance variables (something we haven't seen before). The order of methods stayed the same as in the original file—that's good!

Unfortunately, despite being a runtime analyzer, TypeProf has not so good metaprogramming support. For example, the methods defined using iteration won't be recognized:

```ruby
# a.rb
class A
  %w[a b].each.with_index { |str, i| define_method(str) { i } }
end

p A.new.a + A.new.b
```

```sh
$ typeprof a.rb

# Classes
class A
end
```

(We can handle this with `rbs prototype runtime` 😉)

So, even if you have an executable that provides 100% coverage of your APIs but uses metaprogramming, using just TypeProf is not enough to build a complete types scaffold for your program.

To sum up, all three different ways to generate initial signatures have their pros and cons, but combining their results could give a very good starting point in adding types to existing code. Hopefully, we'll be able to automate this in the future.

In Rubanok's case, I did the following:

- Generating initial signatures using `rbs prototype rb`.
- Ran `typeprof` and used its output to add missing instance variables and update some signatures.
- Finally, ran `rbs prototype runtime` for main classes.

> While I was writing this article, a PR with `attr_reader self.foo` support [has been merged](https://github.com/ruby/rbs/pull/505).

The latter one helped to find a [bug](https://github.com/ruby/rbs/issues/510) in the signature generated at the first step:

```diff
 module Rubanok
-  attr_accessor ignore_empty_values: untyped
-  attr_accessor fail_when_no_matches: untyped
+  def self.fail_when_no_matches: () -> untyped
+  def self.fail_when_no_matches=: (untyped) -> untyped
+  def self.ignore_empty_values: () -> untyped
+  def self.ignore_empty_values=: (untyped) -> untyped
 end
```

## Introducing Steep

So far, we've only discussed how to write and generate type signatures. That would be useless if we don't add a type checker to our dev stack.

As of today, the only type checker supporting RBS is [Steep][].

### `steep init`

Let's add the `steep` gem to our dependencies and generate a configuration file:

```sh
steep init
```

That would generate a default `Steepfile` with some configuration. For Rubanok, I updated it like this:

```rb
# Steepfile
target :lib do
  # Load signatures from sig/ folder
  signature "sig"
  # Check only files from lib/ folder
  check "lib"

  # We don't want to type check Rails/RSpec related code
  # (because we don't have RBS files for it)
  ignore "lib/rubanok/rails/*.rb"
  ignore "lib/rubanok/railtie.rb"
  ignore "lib/rubanok/rspec.rb"

  # We use Set standard library; its signatures
  # come with RBS, but we need to load them explicitly
  library "set"
end
```

### `steep stats`

Before drowning in a sea of types, let's think of how we can measure our signatures' efficiency. We can use `steep stats` to see how good (or bad?) our types coverage is:

```sh
$ bundle exec steep stats --log-level=fatal

Target,File,Status,Typed calls,Untyped calls,All calls,Typed %
lib,lib/rubanok/dsl/mapping.rb,success,7,2,11,63.64
lib,lib/rubanok/dsl/matching.rb,success,26,18,50,52.00
lib,lib/rubanok/processor.rb,success,34,8,49,69.39
lib,lib/rubanok/rule.rb,success,24,12,36,66.67
lib,lib/rubanok/version.rb,success,0,0,0,0
lib,lib/rubanok.rb,success,8,4,12,66.67
```

This command outputs surprisingly outputs CSV 😯. Let's add some Unix magic and make the output more readable:

```sh
$ bundle exec steep stats --log-level=fatal | awk -F',' '{ printf "%-28s %-9s %-12s %-14s %-10s\n", $2, $3, $4, $5, $7 }'
File                         Status    Typed calls  Untyped calls  Typed %   
lib/rubanok/dsl/mapping.rb   success   7            2              63.64     
lib/rubanok/dsl/matching.rb  success   26           18             52.00     
lib/rubanok/processor.rb     success   34           8              69.39     
lib/rubanok/rule.rb          success   24           12             66.67     
lib/rubanok/version.rb       success   0            0              0         
lib/rubanok.rb               success   8            4              66.67  
```

Ideally, we would like to have everything typed. So, I opened my `.rbs` files and started replacing `untyped` with the actual types one by one.

It took me about a dozen minutes to get rid of untyped definitions (most of them). I'm not going to describe this process in detail; it was pretty straightforward except for the one thing I'd like to pay attention to.

Let's recall what Rubanok is. It provides a DSL to define _data_ (usually, user input) transformers of a form `(input, params) -> input`. A typical use case is to customize an Active Record relation depending on request parameters:

```ruby
class PagySearchyProcess < Rubanok::Processor
  map :page, :per_page, activate_always: true do |page: 1, per_page: 20|
   # raw is a user input
   raw.page(page).per(per_page)
  end

  map :q do |q:|
    raw.search(q)
  end
end

PagySearchyProcessor.call(Post.all, {q: "rbs"})
#=> Post.search("rbs").page(1).per(20)

PagySearchyProcessor.call(Post.all, {q: "rbs", page: 2})
#=> Post.search("rbs").page(2).per(20)
```

Thus, Rubanok deals with two external types: _input_ (which could be anything) and _params_ (which is a Hash with String or Symbol keys). Also, we have a notion of _field_ internally: a params key used to activate a particular transformation. A lot of Rubanok's methods use these three entities, and to avoid duplication, I decided to use the _type aliases_ feature of RBS:

```ruby
module Rubanok
  # Transformation parameters
  type params = Hash[Symbol | String, untyped]
  type field = Symbol
  # Transformation target (we assume that input and output types are the same)
  type input = Object?

  class Processor
    def self.call: (params) -> input
                 | (input, params) -> input
    def self.fields_set: () -> Set[field]
    def self.project: (params) -> params

    def initialize: (input) -> void
    def call: (params) -> input
  end

  class Rule
    attr_reader fields: Array[field]

    def project: (params) -> params
    def applicable?: (params) -> bool
  end

  # ...
end
```

That allowed me to avoid duplication and indicate that they are not just Hashes, Strings, or whatever passing around, but _params_, _fields_ and _inputs_.

Now, let's check our signatures!

## Fighting with signatures, or make `steep check` happy

It's very unlikely that we wrote 100% correct signatures right away. I got ~30 errors:

```sh
$ bundle exec steep check --log-level=fatal

lib/rubanok/dsl/mapping.rb:24:8: MethodArityMismatch: method=map (def map(*fields, **options, &block))
lib/rubanok/dsl/mapping.rb:38:10: NoMethodError: type=(::Object & ::Rubanok::DSL::Mapping::ClassMethods), method=define_method (define_method(rule.to_method_name, &block))
lib/rubanok/dsl/mapping.rb:40:10: NoMethodError: type=(::Object & ::Rubanok::DSL::Mapping::ClassMethods), method=add_rule (add_rule rule)
lib/rubanok/dsl/matching.rb:25:10: MethodArityMismatch: method=initialize (def initialize(id, fields, values = [], **options, &block))
lib/rubanok/dsl/matching.rb:26:26: UnexpectedSplat: type= (**options)
lib/rubanok/dsl/matching.rb:29:12: IncompatibleAssignment:  ... 
lib/rubanok/dsl/matching.rb:30:32: NoMethodError: type=::Array[untyped], method=keys (@values.keys)
lib/rubanok/dsl/matching.rb:42:8: MethodArityMismatch: method=initialize (def initialize(*, **))
lib/rubanok/dsl/matching.rb:70:8: MethodArityMismatch: method=match (def match(*fields, **options, &block))
lib/rubanok/dsl/matching.rb:71:17: IncompatibleArguments: ...
lib/rubanok/dsl/matching.rb:73:10: BlockTypeMismatch: ...
lib/rubanok/dsl/matching.rb:75:10: NoMethodError: type=(::Object & ::Rubanok::DSL::Matching::ClassMethods), method=define_method (define_method(rule.to_method_name) do |params = {}|)
lib/rubanok/dsl/matching.rb:83:12: NoMethodError: type=(::Object & ::Rubanok::DSL::Matching::ClassMethods), method=define_method (define_method(clause.to_method_name, &clause.block))
lib/rubanok/dsl/matching.rb:86:10: NoMethodError: type=(::Object & ::Rubanok::DSL::Matching::ClassMethods), method=add_rule (add_rule rule)
lib/rubanok/dsl/matching.rb:96:15: NoMethodError: type=(::Object & ::Rubanok::DSL::Matching), method=raw (raw)
lib/rubanok/processor.rb:36:6: MethodArityMismatch: method=call (def call(*args))
lib/rubanok/processor.rb:56:13: NoMethodError: type=(::Class | nil), method=<= (superclass <= Processor)
lib/rubanok/processor.rb:57:12: NoMethodError: type=(::Class | nil), method=rules (superclass.rules)
lib/rubanok/processor.rb:67:13: NoMethodError: type=(::Class | nil), method=<= (superclass <= Processor)
lib/rubanok/processor.rb:68:12: NoMethodError: type=(::Class | nil), method=fields_set (superclass.fields_set)
lib/rubanok/processor.rb:78:21: ArgumentTypeMismatch: receiver=::Hash[::Symbol, untyped], expected=::Array[::Symbol], actual=::Set[::Rubanok::field] (*fields_set)
lib/rubanok/processor.rb:116:6: NoMethodError: type=::Rubanok::Processor, method=input= (self.input =)
lib/rubanok/processor.rb:134:6: NoMethodError: type=::Rubanok::Processor, method=input= (self.input = prepared_input)
lib/rubanok/rule.rb:11:6: IncompatibleAssignment: ...
lib/rubanok/rule.rb:20:8: UnexpectedJumpValue (next acc)
lib/rubanok/rule.rb:48:12: NoMethodError: type=(::Method | nil), method=call (filter_with.call(val))
lib/rubanok/rule.rb:57:8: MethodArityMismatch: method=empty? (def empty?)
lib/rubanok/rule.rb:63:8: MethodArityMismatch: method=empty? (def empty?)
lib/rubanok/rule.rb:69:4: MethodArityMismatch: method=empty? (def empty?(val))
```

Let's take a closer look at these errors and try to fix them.

### 1. Refinements always break things.

Let's start with the last three reported errors:

```sh
lib/rubanok/rule.rb:57:8: MethodArityMismatch: method=empty? (def empty?)
lib/rubanok/rule.rb:63:8: MethodArityMismatch: method=empty? (def empty?)
lib/rubanok/rule.rb:69:4: MethodArityMismatch: method=empty? (def empty?(val))
```

Why Steep detected three `#empty?` methods in the Rule class? It turned out that it considers an _anonymous_ refinement body to be a part of the class body:

```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 ignore_empty_values

  val.empty?
end
```

I submitted an [issue](https://github.com/soutaro/steep/issues/265) and moved refinements to the top of the file to fix the errors.

### 2. Superclass don't cry 😢 

Another interesting issue relates to `superclass` usage:

```sh
lib/rubanok/processor.rb:56:13: NoMethodError: type=(::Class | nil), method=<= (superclass <= Processor)
lib/rubanok/processor.rb:57:12: NoMethodError: type=(::Class | nil), method=rules (superclass.rules)
lib/rubanok/processor.rb:67:13: NoMethodError: type=(::Class | nil), method=<= (superclass <= Processor)
```

The corresponding source code:

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

It's a very common pattern to inherit class _properties_. Why doesn't it work? First, the `superclass` signature says the result is either Class or `nil` (though it could be nil only for the BaseObject class, as far as I know). Thus, we cannot use `<=` right away (because it's not defined on `NilClass`.

Even if we _unwrap_ `superclass`, the problem with `.rules` would still be there—Steep's flow sensitivity analysis currently doesn't recognize the `<=` operator. So, I decided to hack the system and explicitly define the `.superclass` signature for the Processor class:

```ruby
# processor.rbs
class Processor
  def self.superclass: () -> singleton(Processor)
  # ...
end
```

This way, my code stays the same; only the types suffer 😈.

### 3. Explicit over implicit: handling splats.

So far, we've seen pretty much the same problems as I had with [Sorbet][sorbet-post]. Let's take a look at something new.

Consider this code snippet:

```ruby
def project(params)
  params = params.transform_keys(&:to_sym)
  # params is a Hash, fields_set is a Set
  params.slice(*fields_set)
end
```

It produces the following type error:

```sh
lib/rubanok/processor.rb:78:21: ArgumentTypeMismatch: receiver=::Hash[::Symbol, untyped], expected=::Array[::Symbol], actual=::Set[::Rubanok::field]
```

The `Hash#slice` method expects an Array, but we pass a Set. However, we also use a splat (`*`) operator, which implicitly tries to convert an object to an array—seems legit, right? Unfortunately, Steep is not so smart yet: we have to add an explicit `#to_a` call.

### 4. Explicit over implicit, pt. 2: forwarding arguments.

I used the following pattern in a few places:

```ruby
def match(*fields, **options, &block)
  rule = Rule.new(fields, **options)

  # ...
end
```

A DSL method accepts some options as keyword arguments and then pass them to the Rule class initializer. The possible options are strictly defined and enforced in the `Rule#initialize,` but we would like to avoid declaring them explicitly just to forward down. Unfortunately, that's only possible if we declare `**options` as `untyped`—that would make signatures kinda useless.

So, we have to become more explicit once again:

```diff
-        def map(*fields, **options, &block)
-          filter = options[:filter_with]
-          rule = Rule.new(fields, **options)

+        def map(*fields, activate_on: fields, activate_always: false, ignore_empty_values: Rubanok.ignore_empty_values, filter_with: nil, &block)
+          filter = filter_with
+          rule = Rule.new(fields, activate_on: activate_on, activate_always: activate_always, ignore_empty_values: ignore_empty_values, filter_with: filter_with)
 
# and more...
```

I guess it's time to add [Ruby Next][ruby-next-proposed] and use shorthand Hash notation 🙂

### 5. Variadic arguments: annotations to the rescue!

In the recent Rubanok release, I added an ability to skip input for transformations and only use params as the only `#call` method argument. That led to the following code:

```ruby
def call(*args)
  input, params =
  if args.size == 1
    [nil, args.first]
  else
    args
  end

  new(input).call(params)
end
```

As in the previous case, we needed to make our signature more explicit and specify the actual arguments instead of the `*args`:

```ruby
# This is our signature
# (Note that we can define multiple signatures for a method)
def self.call: (input, params) -> input
             | (params) -> input

# And this is our code (first attempt)
UNDEFINED = Object.new

def call(input, params = UNDEFINED)
  input, params = nil, input if params == UNDEFINED

  raise ArgumentError, "Params could not be nil" if params.nil?

  new(input).call(params)
end
```

This refactoring doesn't pass the type check:

```sh
$ bundle exec steep lib/rubanok/processor.rb

lib/rubanok/processor.rb:43:24: ArgumentTypeMismatch: receiver=::Rubanok::Processor, expected=::Rubanok::params, actual=(::Rubanok::input | ::Rubanok::params | ::Object) (params)
```

So, according to Steep, `param` could be pretty match anything :( We need to help Steep to make the right decision. I couldn't find a way to do that via RBS, so my last resort was to use [annotations][steep-annotations].

Yes, even though RBS itself is designed not to pollute your source code, Steep allows you to do that. And in some cases, that's the necessary evil.

I came up with the following:

```ruby
def call(input, params = UNDEFINED)
  input, params = nil, input if params == UNDEFINED

  raise ArgumentError, "Params could not be nil" if params.nil?

  # @type var params: untyped
  new(input).call(params)
end
```

We declare `params` as `untyped` to silence the error. The `#call` method signature guarantees that the `params` variable satisfies the `params` type requirements, so we should be safe here.

### 6. Deal with metaprogramming: interfaces.

Since Rubanok provides a DSL, it heavily uses metaprogramming.
For example, we use `#define_method` to dynamically generate transformation methods:

```ruby
def map(*fields, activate_on: fields, activate_always: false, ignore_empty_values: Rubanok.ignore_empty_values, filter_with: nil, &block)
  # ...
  rule = Rule.new(fields, activate_on: activate_on, activate_always: activate_always, ignore_empty_values: ignore_empty_values, filter_with: filter_with)

  define_method(rule.to_method_name, &block)

  add_rule rule
end
```

And that's the error we see when running `steep check`:

```sh
lib/rubanok/dsl/mapping.rb:38:10: NoMethodError: type=(::Object & ::Rubanok::DSL::Mapping::ClassMethods), method=define_method (define_method(rule.to_method_name, &block))
lib/rubanok/dsl/mapping.rb:40:10: NoMethodError: type=(::Object & ::Module & ::Rubanok::DSL::Mapping::ClassMethods), method=add_rule (add_rule rule)
```

Hmm, looks like our type checker doesn't know that we're calling the `.map` method in the context of the Processor class (we call `Processor.extend DSL::Mapping`).

RBS has a concept of a _self type_ for module: a self type adds requirements to the classes/modules, which include/prepend/extend this module. For example, we can state that we only allow using `Mapping::ClassMethods` to extend modules (and not objects, for example):

```ruby
# Module here is a self type
module ClassMethods : Module
  # ...
end
```

That fixes `NoMethodError` for `#define_method,` but we still have it for `#add_rule`—this is a Processor self method. How can we add this restriction using module self types? It's not allowed to use `singleton(SomeClass)` as a self type; only classes and _interfaces_ are allowed. Yes, RBS has interfaces! Let's give them a try!

We only use the `#add_rule` method in the modules, so we can define an interface as follows:

```ruby
interface _RulesAdding
  def add_rule: (Rule rule) -> void
end

# Then we can use this interface in the Processor class itself
class Processor
  extend _RulesAdding

  # ...
end

# And in our modules
module Mapping
  module ClassMethods : Module, _RulesAdding
    # ...
  end
end
```

### 7. Making Steep happy.

> Other problems I faced with Steep which I converted into issues:
- [Method visibility false negatives](https://github.com/soutaro/steep/issues/266)
- [Inability to declare block return values in some cases](https://github.com/soutaro/steep/issues/261)


I added a few more changes to the signatures and the source code to finally make a `steep check` pass. The journey was a bit longer than I expected, but in the end, I'm pretty happy with the result—I will continue using RBS and Steep.

Here is the final stats for Rubanok:

```sh
File                         Status    Typed calls  Untyped calls  Typed %   
lib/rubanok/dsl/mapping.rb   success   11           0              100.00    
lib/rubanok/dsl/matching.rb  success   54           2              94.74     
lib/rubanok/processor.rb     success   52           2              96.30     
lib/rubanok/rule.rb          success   31           2              93.94     
lib/rubanok/version.rb       success   0            0              0         
lib/rubanok.rb               success   12           0              100.00  
```

## Runtime type checking with RBS

Although RBS doesn't provide static type checking capabilities, it comes with [runtime testing utils][rbs-test]. By loading a specific file (`rbs/test/setup`), you can ask RBS to watch the execution and check that method calls inputs and outputs satisfy signatures.

Under the hood, [TracePoint API is used][rbs-test-setup] along with the [alias method chain trick][rbs-test-amc] to hijack observed methods. Thus, it's meant for use in tests, not in production.

Let's try to run our RSpec tests with runtime checking enabled:

```sh
$ RBS_TEST_TARGET='Rubanok::*' RUBYOPT='-rrbs/test/setup' bundle exec rspec --fail-fast

I, [2020-12-07T21:07:57.221200 #285]  INFO -- : Setting up hooks for ::Rubanok
I, [2020-12-07T21:07:57.221302 #285]  INFO -- rbs: Installing runtime type checker in Rubanok...
...

Failures:

  1) Rails controllers integration PostsApiController#planish implicit rubanok with matching
     Failure/Error: prepare! unless prepared?
     
     RBS::Test::Tester::TypeError:
       TypeError: [Rubanok::Processor#prepared?] ReturnTypeError: expected `bool` but returns `nil`
```

Oh, we forgot to initialize the `@prepared` instance variable with the boolean value! Nice!

> When I tried to use RBS runtime tests for the first time, I encountered [a few severe problems](https://github.com/ruby/rbs/issues?q=is%3Aissue+author%3Apalkan+is%3Aclosed). Many thanks to [Soutaro Matsumoto][soutaro] for fixing all of them faster than I finished working on this article!

I found a couple of more issues by using `rbs/test/setup`, including the one I wasn't able to resolve:

```sh
Failure/Error: super(fields, activate_on: activate_on, activate_always: activate_always)

RBS::Test::Tester::TypeError:
  TypeError: [Rubanok::Rule#initialize] UnexpectedBlockError: unexpected block is given for `(::Array[::Rubanok::field] fields, ?filter_with: ::Method?, ?ignore_empty_values: bool, ?activate_always: bool, ?activate_on: ::Rubanok::field | ::Array[::Rubanok::field]) -> void`
```

And here is the reason:

```ruby
class Clause < Rubanok::Rule
  def initialize(id, fields, values, **options, &block)
    # The block is passed to super implicitly,
    # but is not acceptable by Rule#initialize
    super(fields, **options)
  end
end
```

I tried to use `&nil` to disable block propagation, but that broke `steep check` 😞. I submitted [an issue](https://github.com/soutaro/steep/issues/268) and excluded `Rule#initialize` from the runtime checking for now using a special comment in the `.rbs` file:

```ruby
# rule.rbs
class Rule
  # ...
  %a{rbs:test:skip} def initialize: (
    Array[field] fields,
    ?activate_on: field | Array[field],
    ?activate_always: bool,
    ?ignore_empty_values: bool,
    ?filter_with: Method?
  ) -> void
end
```

## Bonus: Steep meets Rake

I usually run `be rake` pretty often during development to make sure that everything is correct. The default task usually includes RuboCop and tests.

Let's add Steep to the party:

```ruby
# Rakefile

# other tasks

task :steep do
  # Steep doesn't provide Rake integration yet,
  # but can do that ourselves 
  require "steep"
  require "steep/cli"

  Steep::CLI.new(argv: ["check"], stdout: $stdout, stderr: $stderr, stdin: $stdin).run
end

namespace :steep do
  # Let's add a user-friendly shortcut
  task :stats do
    exec %q(bundle exec steep stats --log-level=fatal | awk -F',' '{ printf "%-28s %-9s %-12s %-14s %-10s\n", $2, $3, $4, $5, $7 }')
  end
end

# Run steep before everything else to fail-fast
task default: %w[steep rubocop rubocop:md spec]
```


## Bonus 2: Type Checking meets GitHub Actions

As the final step, I configure GitHub Actions to run both static and runtime type checks:

```yml
# lint.yml
jobs:
  steep:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - uses: ruby/setup-ruby@v1
      with:
        ruby-version: 2.7
    - name: Run Steep check
      run: |
        gem install steep
        steep check

# rspec.yml
jobs:
  rspec:
    # ...
    steps:
      # ...
    - name: Run RSpec with RBS
      if: matrix.ruby == '2.7'
      run: |
        gem install rbs
        RBS_TEST_TARGET="Rubanok::*" RUBYOPT="-rrbs/test/setup" bundle exec rspec --force-color
    - name: Run RSpec without RBS
      if: matrix.ruby != '2.7'
      run: |
        bundle exec rspec --force-color
```

----

Although there are still enough rough edges, I enjoyed using RBS/Steep a bit more than "eating" Sorbet (mostly because I'm not a big fan of type annotations in the source code). I will continue adopting Ruby 3 types in my OSS projects and reporting as many issues to RBS/Steep as possible 🙂.

P.S. You can find the source code in [this PR](https://github.com/palkan/rubanok/pull/15/files).

[rbs]: https://github.com/ruby/rbs
[Sorbet]: https://sorbet.org
[sorbet-post]: https://evilmartians.com/chronicles/sorbetting-a-gem
[Steep]: https://github.com/soutaro/steep
[Rubanok]: https://github.com/palkan/rubanok
[typeprof]: https://github.com/ruby/typeprof
[typeprof-doc]: https://github.com/ruby/typeprof/blob/master/doc/doc.md
[active-record-method-gen]: https://github.com/rails/rails/blob/5384bbc4ce937b4d539518b4e3cefb91e03d4f80/activerecord/lib/active_record/relation/query_methods.rb#L111-L132
[ruby-next-proposed]: https://github.com/ruby-next/ruby-next#proposed-and-edge-features
[steep-annotations]: https://github.com/soutaro/steep/blob/master/manual/annotations.md
[rbs-test]: https://github.com/ruby/rbs/blob/master/docs/sigs.md#setting-up-the-test
[rbs-test-setup]: https://github.com/ruby/rbs/blob/71b8e534e0adcc78aa76ffbd0326ffe01594d520/lib/rbs/test/setup.rb#L62-L73
[rbs-test-amc]: https://github.com/ruby/rbs/blob/71b8e534e0adcc78aa76ffbd0326ffe01594d520/lib/rbs/test/hook.rb#L50
[soutaro]: https://github.com/soutaro