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 が提供するメソッドを使うことを想定しているので、
こうした指摘を受けるのは正しいのだけど困ってしまいますよね。
この問題を 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 を目指しています。
興味があれば利用してみてください。