Rails + RBS & Steepを試してみて困ったところとか感想とか

## まえがき

Railsアプリに型チェックを導入しようとして試しているのですが、なかなか一筋縄ではいかないようで、試行錯誤しています。

おそらく他にも似たようなことでハマったりしている人もいるかと思うので、これまで困ったこと、そして（回避できた場合は）回避策を書いてみます。

なお、RBSやSteepとはなんぞやといった辺りは特に説明せずにいきなり本題に入るため、詳しくない方は[mameさんの記事「Ruby 3の静的解析機能のRBS、TypeProf、Steep、Sorbetの関係についてのノート」](https://techlife.cookpad.com/entry/2020/12/09/120454)などを参考にしてください。

## Steep & RBSで困ったところ

### BigDecimalを導入してもto_dが使えない

（追記: ksssさんにrbsのstdlibのテストの書き方を教えてもらったので書けるようになりました！　結果は[pull request](https://github.com/ruby/rbs/pull/936)してみたので、最終的に取り込まれれば使えるようになるはず。）

Steepfileに`library "bigdecimal"`を書いてもダメでした。rbsの定義が必要そうですが、stdlibのテストの書き方がよく分からない…(assert_send_typeを使ってもmethod_typesでエラーになって挫折しました…)。

とりあえずローカルのrbsファイルに以下のように書くと回避できます。

```ruby
class Integer
  def to_d: () -> BigDecimal
end
class String
  def to_d: () -> BigDecimal
end
class Float
  def to_d: () -> BigDecimal
end
```

ちょっとベタですが、どうせアプリ内ではどこでも使えるメソッドですし、理解しやすくはありそうです。

この手の既存クラスにメソッドを生やすやつは、こんな感じに適宜メソッドを追加していくことになりそうです。

### `include ActiveModel::Validations`をしたクラスでもvalidationメソッドが使えない

ActiveRecordでもないモデルクラスとして、ActiveModelを使ってvalidationを書きたくなることがあります。

そうした場合、`include ActiveModel::Validations`をした上で`validates :foo, presence: true`のように書くかと思いますが、そうすると以下のようなエラーになります。

```
[error] Type `singleton(::Foo)` does not have method `validates`
│ Diagnostic ID: Ruby::NoMethod`
```

仕方がないのでrbsファイルに以下のように書いて、明示的にvalidationのクラスメソッドを有効にすると回避できます。

```ruby
class Foo
  include ActiveModel::Validations
  extend ActiveModel::Validations::ClassMethods

  # ...
end
```

### `include ActiveModel::Model`をしたクラスでもvalidationが使えない

上に同じです。

### `module ClassMethods`が効いてなさそう

上記の話のそもそもの原因ですが、Railsの場合、`module ClassMethods`を使ってクラスメソッドを定義しているコードを普通に使うかと思います。
ところが、これはRubyとして解釈されるから機能するわけで、この辺りをそのままrbsに書いてもクラスメソッドの定義としては解釈されないのは当然ですよね……。

案外RBS用のmodule用構文として、includeするとsingletonメソッドがクラスメソッドになるようなものが記述できるとしあわせになれるかも、と思いました。
`include`の代わりに`include_singleton`と書く、みたいなやつです（あまり深く考えず思いつきで書いています）。

### ActiveRecordのvalidationで`validate :foobar, if: -> { new_record? }` のように書いたときに `Diagnostic ID: Ruby::NoMethod`になる

「Type `singleton(::FooModel)` does not have method `new_record?`」みたいなエラーが出てしまうのでした。
これはif:のブロック内について、クラスメソッドで探索しているのですが、実際にはインスタンスメソッド（が使えるようになっている）だからですよね。
こういったif:ブロック等の中はインスタンスレベルのメソッドとして解釈してほしいのですが、どうにかできないですかね…？

### `Foo = Struct.new(.....)`というコードを普通に記述できない

RubyでStructクラスを使う場合、普通は定数にStruct.newの結果を代入して、その定数をクラスのように使うかと思います。
が、素朴にこう書くと、SteepでRuby::IncompatibleAssignmentのエラーが出てしまいます。

[rbsのリポジトリ](https://github.com/ruby/rbs)を見る限りでは、このような場合には`Foo = _ = Struct.new(.....)`という書き方をするようです。
が、これは不自然ですし、見た目的にも分かりやすくはないと思います。もうちょっと自然なRubyのコードを書いて、うまいこと解釈していただきたいところです。

どちらかというと後述の型チェックを抑制するようなコメントを使えるようにした方がマシなような気がします。が、慣れの問題もあるかも。

### RuboCopのdisable/enableみたいなことが書けない

Steepを本番投入するのにいちばん重要なのは、部分的にのみ型チェックを行うようにして、その「部分」を柔軟に指定できることではないかと思いました。

具体的には、この行からこの行まで無視してほしい、みたいなときに、RuboCopのようにコメントで指定できると良さそうです。

先ほどのif:ブロックの例だと、以下のように書いて回避する感じです。


```ruby
class FooModel
  include ActiveModel::Validations

  # steep:disalbe Ruby::NoMethod
  validate :foobar, if: -> { new_record? }
  # steep:enalbe Ruby::NoMethod

  # ...
end
```

### 特定のファイルだけチェックしたいときにsteep checkの引数で指定できない

上と同じような話で、1ファイルだけテストしたいときにSteepfileを都度変更するのはちょっと面倒な感じでした。
この辺りはコマンドラインオプションで指定できると、作業的に楽になりそうな気がします。

### I18n.tが通らない

→ pull request送りました。 https://github.com/ruby/gem_rbs_collection/pull/130

### Time.zone.now.yesterdayが通らない

→ pull request送りました。 https://github.com/ruby/gem_rbs_collection/pull/134

その他もろもろpull requestしてみています。


## とりあえずの感想

そんなわけでSteepがちゃんと通るようになるのはまだまだ先は長そうなのですが、とりあえず現時点での感想を書いておきます。

### gem_rbs_collectionは「published」なAPIのみでいいかも？

publishedなAPIというのは、Martin Fowlerの言う[公布済みインターフェイス](https://bliki-ja.github.io/PublishedInterface/)のことです。

[gem_rbs_collection](https://github.com/ruby/gem_rbs_collection)について説明しておくと、これは様々なgemのRBSを集積するためのリポジトリです。
そもそもgemのRBSを記載し共有するには2つの方法があります。

* 各gemの中に書いて、一緒に公開する（標準ではそのgem内のsig/ディレクトリに書く）
* gem_rbs_collectionにgemのrbsを追加する

前者はそのgemの開発者（チーム）自身が書くもので、後者は主にgem開発者ではない人がそのgemをRBSを使うために書くものになるかと思います。

そう考えると、privateメソッドも含めたgemの網羅的なRBSはgem開発者が書いたり使ったりするもので、単にそのgemを使いたい人にとってはRBSで記述してほしいAPIはpublicの中でも公布済みインターフェイスと思われるものだけになりそうです。
そもそもprivateなメソッドの仕様を外部の人が考えたり決めたりするというのも変な話ですし。

（ただし、moduleについてはprivateなメソッドであっても、そのmoduleをincludeしたクラスから利用する前提で作られている場合もありますよね…。そこはちょっと扱いが難しいかもしれません。）

実際、gem_rbs_collectionの[Contribution Guide](https://github.com/ruby/gem_rbs_collection/blob/main/docs/CONTRIBUTING.md)にも「Focus on the most important part of the API」「Focus on examples available through the README or docs of the gem. Focus on the APIs your app is using.」と書かれているわけで、この辺はそういうことかもと思いました。

もちろん、開発しているアプリ自体の`sig/`ディレクトリに置くRBSは、また別の開発指針が考えるのが良さそうです。


### (Yet another) developer testingとしてのRBS

RBSの「書き味」は、なんとなくRSpecでテストを書くのに似ているような気がします。
そう考えると、RBSとSteepに求めるのは厳密な「型」やその代替物ではなく、「開発を駆動するためのツールとしくみ」であると考えた方がよいかもしれません。

というのも、Rubyで型安全な世界、あるいはそれに近い世界が早期に実現することは現実的ではなさそうなことに加えて、そこに注力するのもなにか違うのではという感触があります。まあ実際のところそうですよね。
そして現状のRBSの手応えは、開発者テスト(developer testing)の世界で、完全なテストを書くことが現実的ではないことに似ているような気がするのでした。

しかし、開発者テストが完全ではなくても、開発者テストが便利なものであることは、日々の開発の中で実感されている方も多いはずです。
RBSも同様に、gem開発者とgem_rbs_collection開発者とアプリ開発者がそれぞれ協力して、みんなでみんなのアプリやライブラリが開発しやすくなるように育てていくためのしくみだと考えてみるのも、あながち間違ってはいないのではないでしょうか。


### 現状のRBS・Steepの使い勝手

Rails関連についてはまだ正しいメソッドがエラーになることがありそうだった（気づいたところはPull Requestしました）ので、追加・修正はまだまだ必要かもしれません。
もっとも、これはみんなで触っていればすぐ気づくところではあるので、それほど時間がかからず改善されるとは思います。

そして現状でもnull(nil)チェックが甘いところは普通に見つかるようです。これはありがたいかもしれません。

あとTypeScriptでいうところの型ガード的なものはまだ使えないんでしたっけ？
そうすると無駄な検知が増えるので使い勝手がよろしくないかもしれません。
とりあえず上で書いたコメントでの抑制ができると多少は有効そうですが、やや限界もあるかも。

とはいえ型ガードが使えるようになったとして、Rubyのコードの中に型ガード的な記述が増えすぎるのもそれはそれでうれしくないかもしれないですね…。
そこまで到達するのはもう少し先になりそうですが、むしろそうなってからがRubyと型との関係性をどうするべきかの突っ込んだ議論が始まりそうです。


### 頑張っていきましょう

また、例えばRailsで普通に使われているgemについても、まだまだgem_rbs_collectionにないものもありそうです。
先ほどの点と合わせてこれは時間が解決する問題かとは思いますが、その時間を圧縮するにはより多くの人の参加と協力が必要になりそうです。

まずは粛々とgem_rbs_collectionを整備していければ良さそうなので、そのためにもまずは開発者・利用者を増やしつつ、便利なものにしていければいいんではないかと思います。興味のある方はぜひ試してみてはいかがでしょうか。
頑張っていきましょう。