RBS の self-type を理解する


2023年 09月 14日

Rails アプリケーションに型付けを進める中で、同僚と話題になった concern の扱いについて紹介します。
ご存知の通り、concern は「関心事を分離したもの」とよく紹介されるモジュールで、モデルや Controller などで include して使う共通モジュールです。
この concern に対して型付けをしようとしても、うまく行かないね、というのが今回の話の発端です。

話をイメージしやすいよう、ログイン状態をチェックする Loginable という concern をサンプルとして用意してみました。

module Loginable
  def require_logged_in
    redirect_to login_path if session[:user_id].blank?
  end
 
  def login(user)
    session[:user_id] = user.id
  end
end

#login メソッドや、before_action 用のログインチェックメソッドである #require_logged_in などを持つ、どこにでもありそうな concern です。
この concern は Controller に include して使うことを想定しています。

そして、この concern に型付けしてみるとこんな感じになります。

module Loginable
  def require_logged_in: () -> void
  def login: (User user) -> void
end

ここまでは通常の型の導入ですね。

型チェックしてみる

さて、ここからが本題です。さきほど用意した Loginable  concern を Steep で型チェックしてみましょう。

app/controllers/concerns/loginable.rb:3:30: [error] Type `(::Object & ::Loginable)` does not have method `session`
│ Diagnostic ID: Ruby::NoMethod
│
└     redirect_to login_path if session[:user_id].blank?
                                ~~~~~~~
app/controllers/concerns/loginable.rb:3:16: [error] Type `(::Object & ::Loginable)` does not have method `login_path`
│ Diagnostic ID: Ruby::NoMethod
│
└     redirect_to login_path if session[:user_id].blank?
                  ~~~~~~~~~~
app/controllers/concerns/loginable.rb:3:4: [error] Type `(::Object & ::Loginable)` does not have method `redirect_to`
│ Diagnostic ID: Ruby::NoMethod
│
└     redirect_to login_path if session[:user_id].blank?
      ~~~~~~~~~~~
app/controllers/concerns/loginable.rb:7:4: [error] Type `(::Object & ::Loginable)` does not have method `session`
│ Diagnostic ID: Ruby::NoMethod
│
└     session[:user_id] = user.id
      ~~~~~~~

おやおや、エラーが出てしまっていますね。

エラーを読んでみると、Steep は Loginable モジュールには #redirect_to#login_path#session といったメソッドがないと指摘しています。
たしかに Loginable モジュールにはこれらのメソッドは定義されていないのですが、
これらのメソッドは include 先の Controller が提供するメソッドを使うことを想定しているので、
こうした指摘を受けるのは正しいのだけど困ってしまいますよね。

module-self-types を指定しよう

この問題を ruby-jp slack の #types 部屋で相談してみたところ、 @ksss さんが以下のイシューを教えてくれました。ありがとうございます。

How to deal a module included by a specified class like ActiveRecord::Base? · Issue #1108 · ruby/rbs

このイシューで紹介されている、モジュール定義における module-self-types という指定が、今回のケースでは有効です。

module-self-types を指定すると、該当のモジュールで self とみなす型(クラス)を設定できます。
今回のケースでは Loginable モジュールはいくつかの Controller に include することを想定しているため、
module-self-types として共通の親クラスである ApplicationController を指定するのが良さそうです。

module-self-types は以下のように module 〜 のあとにコロンとクラス名を記載します。

module Loginable : ApplicationController
  def require_logged_in: () -> void
  def login: (User user) -> void
end

先程挙げた指摘は、この module-self-types の設定によりすべて解決しました。

module-self-types は複数の型を指定できるようになっているので、いくつかのクラスから include する場合はカンマ区切りでクラス名を並べることもできるようです。
手元にはそういったモジュールは存在しないので実験はしていないのですが、必要に応じて試してみてください。

また、実験していて気づいたのは、module-self-types に循環参照となるような型を指定するとエラーが発生してしまうことです。
たとえば ApplicationController から include している concern に対して、module-self-types に ApplicationController を指定することはできません。

幸いにも手元のケースでは、そうしたモジュールが存在しなかったので問題は起きなかったのですが、コードによっては実装や定義を見直す必要がでてきそうです。
Controller と concern でお互いに依存してしまっているというのはなんだか危険な香りがしますしね。
見直しのいい機会かもしれません。

蛇足:フィルタを作ってみました

module-self-types 欄に型を書き足してあげればよいというのはわかりましたが、あちこちを書き換えていくのはちょっと面倒くさいですよね。
そんなあなたのために、フィルタを作ってみました。

以前から自分のためにちょこちょこ手掛けている rbs_heuristic_prototype gem に、controller concerns filter というのを追加しようとしています。
app/controllers/concerns ディレクトリ以下にある concern に対して ApplicationController という module-self-types を自動で付けていくというフィルタです。
これを利用すると rbs prootype rb で生成した型定義に対して、コマンドひとつで型抽出ができます。

Add ControllerConcernsFilter by tk0miya · Pull Request #44 · tk0miya/rbs_heuristic_prototype

rbs_heuristic_prototype gem はこうした「よくやる書き換え」を自動化するのを目的としていて、”better” rbs prototype rb を目指しています。

興味があれば利用してみてください。

まとめ

  • 特定のクラスに include する concern の型定義には module-self-types を使うと便利です
  • コマンドひとつで module-self-types を注入するフィルタを作ってみました
  • @ksss さん情報ありがとうございます 🙇