型定義を書いてみよう (準備編)


2023年 05月 23日

こんにちは、@tk0miya です。

そろそろ RubyKaigi 2023 の余韻も冷めてきたところなのですが、持って返ってきたお土産タスクはまだまだ山盛りなので、少しずつ調べていこうと思っています。

さて、前回は Ruby の型定義情報を使って irb の入力をリッチにする katakata_irb を紹介しました
rbs_rails を使うと Rails アプリから型定義が自動抽出できることも学びましたよね。

katakata_irb と rbs_rails の組み合わせだけでも、かなり強力な補完をしてくれますが、これに加えて自分で定義したメソッドに型を定義することで、さらに入力補完をパワーアップすることができます。

今回は、その型を定義する準備として、環境づくりの方法を紹介します。

最新の rbs パッケージをインストールする

rbs-3.1.0 で導入された rbs subtract コマンドを利用するため、最新の rbs パッケージをインストールします。

katakata_irb パッケージは rbs に依存しているので本来は Gemfile への追加は必要ないのですが、
利用したいバージョンが決まっているため、依存関係を明示しておくと良いでしょう。

gem 'rbs', '>= 3.1.0'

なお、LSP サーバである solargraph を使っている場合は rbs-3.x 系はインストールできません。
solargraph は rbs-2.x 系に依存しているためコンフリクトが発生します。
手元では solargraph に手を加え、rbs-3.x 系で動作するようにしたものを利用しました。
これを利用する場合は、Gemfile を以下のように書き換えるとよいでしょう。

# TODO: rbs-3.x を利用するため、solargraph にパッチを当てたものを利用する。
#       https://github.com/castwide/solargraph/pull/662 マージ後に solargraph 本体に切り替える
gem 'solargraph', require: false, github: 'tk0miya/solargraph', branch: :update_rbs

rbs subtract とは?

rbs-3.1.0 で導入された rbs subtract コマンドは、複数の型定義ファイル (.rbs ファイル) 間で重複している定義を取り除いてくれるコマンドです。
複数のツールを使って .rbs ファイルを生成する場合や、ツールと手書きの型定義を組み合わせる場合など、
ツールを利用する場合は型定義ファイル間で型定義が重複しやすいため、rbs subtract コマンドを使って重複を除去するのがよいでしょう。

詳しくは RubyKaigi 2023 の Let’s write RBS! – RubyKaigi 2023 (トーク資料) や Desgin Doc of rbs subtract を参照してください。

Rake タスク rbs:setup を導入する

先ほど紹介した Let’s write RBS! のトークでは、rbs や rbs_rails を組み合わせて、型定義を “いい感じに” 管理してくれる便利 Rake タスクである rbs:setup を提供しています。

この Rake タスクはよく練られており非常に便利な構造になっているので、拝借させていただくことにします。

curl -L -o lib/tasks/rbs.rake https://raw.githubusercontent.com/pocke/rubykaigi-2023-lets-write-rbs/master/rbs.rake

この rbs:setup タスクは以下の処理で構成されています。

  • ダウンロードや生成した型定義ファイルのクリーンアップ
  • rbs collection installの実行
  • rbs prototype rb の実行
  • Rake タスクrbs_rails:all の実行
  • rbs subtract の実行

これらを連続で実行することにより、型定義ファイルを最新に保つことができます。

実際に実行してみましょう。

bundle exec rails rbs:setup

rbs:setup タスクを実行すると、以下の型定義情報がダウンロードもしくは生成されます。

  • .gem_rbs_collection/ … 3rd party gem の型定義
  • sig/rbs_rails/ … Rails アプリから抽出された型定義。DBスキーマや association などをベースにしたもの。
  • sig/prototype/ … Rails アプリから生成された型定義のテンプレート。実装したメソッドなどが定義されている。ただし、すべての方は untyped 扱い。

そして、sig ディレクトリ以下の型情報の重複している定義は rbs subtract で除去された状態になっています。

※ 当初公開された rbs.rake には誤りがあります。https://github.com/pocke/rubykaigi-2023-lets-write-rbs/pull/1 がマージされるのをお待ちください。

自動生成された型定義の調整

ここまでのステップで、Rails アプリケーションから型定義を抽出できました。
しかし、これで作業は完了ではありません。

rbs prototype rb コマンドが生成する型定義は、ソースコードを静的解析して機械的に生成したものであり、生成時点では動作が保証されていません。
そのため、生成直後の状態で型情報をロードしようとするとエラーが発生することがあります。
この状態で katakata_irb を起動すると、(特定のクラスの) 入力補完が効かなくなってしまいます。

こうした問題を解決するため、rbs validate コマンド使って、型定義情報の状態を検証します。
先ほど導入した rbs.rake には rbs:validate という Rake タスクが定義されているため、このタスク経由で rbs validate コマンドを起動しましょう (オプションを覚える必要がないので楽です)。

bundle exec rails rbs:validate

型定義情報が正しく生成されている場合は、特にエラーなどなくコマンドが終了します。
rbs validate コマンドがクラッシュした場合は、何かしら定義に問題があるため微調整を行う必要があります。

自分のケースでは以下の調整が必要でした。

  • 3rd party gem のダミークラス、ダミーモジュールの定義
  • Enumerable の型調整
  • 3rd party gem のメソッドをオーバーライドしている箇所の調整

それでは、それぞれのケースをひとつずつ説明していきます。

3rd party gem のダミークラス、ダミーモジュールの定義

前回の記事でも紹介しましたが、3rd party gem の型定義情報は rbs collection install コマンドを実行した際に gem_rbs_collection リポジトリからダウンロードされます。
このリポジトリには、有志の手によってよく使われている gem の型定義情報が収集・収録されているのですが、
2023年5月時点で登録されているのは約70個で、まだ収録されている型定義情報は少ないと言わざるを得ません。

そのため、型定義情報が収録されていない 3rd party gem を利用している場合、rbs validate でエラーになることがあります。

具体的には、

  • 3rd party gem のクラスを継承したクラスを定義している
  • 3rd party gem のモジュールを includeしたクラスを定義している

と言った場合に、rbs validate がエラーを吐きます。

以下の例は、CanCanCan gem を利用している Rails アプリのエラーです。

root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
/usr/local/bundle/gems/rbs-3.1.0/lib/rbs/errors.rb:232:in `check!': sig/prototype/app/abilities/ability.rbs:2:2...2:25: Could not find mixin: CanCan::Ability (RBS::NoMixinFoundError)
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:347:in `block in mixin_ancestors0'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/ast/declarations.rb:48:in `each'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/ast/declarations.rb:48:in `each_mixin'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:338:in `mixin_ancestors0'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:397:in `block in mixin_ancestors'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:389:in `each'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:389:in `mixin_ancestors'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:253:in `one_instance_ancestors'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:423:in `instance_ancestors'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:162:in `block in build_instance'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:158:in `build_instance'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:467:in `block in run_validate'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `each'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `run_validate'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:137:in `run'
        from /usr/local/bundle/gems/rbs-3.1.0/exe/rbs:7:in `<top (required)>'
        from /usr/local/bundle/bin/rbs:25:in `load'
        from /usr/local/bundle/bin/rbs:25:in `<main>'
rails aborted!
Command failed with status (1): [rbs -Isig validate --silent...]
/app/lib/tasks/rbs.rake:36:in `block (2 levels) in <main>'
Tasks: TOP => rbs:validate
(See full trace by running task with --trace)

CanCan::Ability モジュールの型定義が存在しないため、ability.rbs が処理できないというエラーが発生しています。

理想としては、不足している型定義を作り上げた上で、gem_rbs_collection にコントリビュートするというのが望ましいのですが、ひとまず rbs validate がパスするように、ダミーの型を定義します。

不足している 3rd party gem の型として、 sig/gems/cancan/cancan/ability.rbs に以下の内容を保存します。

# sig/gems/cancan/cancan/ability.rbs
module CanCan
  module Ability
  end
end

定義する内容は空のモジュールです。普通の Ruby のソースコードと同じ書き方でモジュールを定義すればオッケーです。また、型情報を記述するファイルは拡張子 .rbs を用います。

※ sig/ ディレクトリ以下のファイル配置ルールは決まっていないようです。この記事では 3rd party gem の型情報を sig/gems/{gem_name}/ 以下にファイルを作ることにしました。

 

先ほどの例はモジュールの include 時のエラーでしたが、エラーがクラスの継承で発生している場合は、同様に空のクラスを定義しましょう。
次の例は Draper::Decorator クラスのダミーの型定義です。

# sig/gems/draper/draper/decorator.rbs
module Draper
  class Decorator
  end
end

これらのファイルを作成した後、再度 rbs:validate を実行すると問題が解消しているはずです。

すべてのエラーが解消するまで、同様にダミーの型定義を作成しましょう。
手元の中規模プロジェクトでは 9個分のダミー型定義を作成しました (ActionCable, ActiveHash, CanCan, CarrierWave, ConnectionPool, Devise, Discard, Draper, OperatorRecordable)。

※ 今回はエラーの回避を優先して、3rd party gem の型定義は行いません。

Enumerable の型調整

自作クラスが Enumerable モジュールを include している場合も、rbs validate はエラーを発生させます。

root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
/usr/local/bundle/gems/rbs-3.1.0/lib/rbs/errors.rb:90:in `check!': sig/prototype/app/models/cookie_jar/access_histories.rbs:14:2...28:5: ::Enumerable expects parameters [Elem], but given args [] (RBS::InvalidTypeApplicationError)
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition.rb:241:in `apply'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:454:in `block in instance_ancestors'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:451:in `each'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:451:in `instance_ancestors'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:162:in `block in build_instance'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:158:in `build_instance'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:467:in `block in run_validate'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `each'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `run_validate'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:137:in `run'
        from /usr/local/bundle/gems/rbs-3.1.0/exe/rbs:7:in `<top (required)>'
        from /usr/local/bundle/bin/rbs:25:in `load'
        from /usr/local/bundle/bin/rbs:25:in `<main>'
rails aborted!
Command failed with status (1): [rbs -Isig validate --silent...]
/app/lib/tasks/rbs.rake:36:in `block (2 levels) in <main>'
Tasks: TOP => rbs:validate
(See full trace by running task with --trace)

このエラーは、rbs prototype rb コマンドが生成した型定義ファイルが不完全であるために発生しています。
.rbs ファイルでは Enumerable モジュールを include する際に、どういう型のデータを列挙するのかを宣言する必要があります。
しかし、rbs prototype rb コマンドはどういうデータを列挙しているのかまで判別できないため、こうしたエラーが発生します。

この問題を解決するには、該当のクラスで列挙するデータ型を宣言します。
新たに型定義ファイルをつくり、そこに正しい型定義の include 宣言を書きましょう。

先ほどの例では CookieJar::AccessHistories クラスでエラーになっているため、sig/handwritten/app/models/cookie_jar/access_histories.rbs ファイルを作成し、以下のように型定義を書きます。

module CookieJar
  class AccessHistories
    include Enumerable[AccessHistory]
  end
end

注) sig/prototype/ 以下のファイルを書き換えないでください。sig/prototype/ 以下のファイルは rbs:setup を実行するたびに再生成されるため、変更した内容は消えてしまいます。

この型定義はモジュールとクラスを定義し、その中で列挙する型を指定して Enumerable モジュールを include しています。
CookieJar::AccessHistoriesAccessHistory オブジェクトを列挙するので、 include Enumerable[AccessHistory] という指定をしています。

型定義を書き加えたあと、もう一度 rbs:setup タスクを実行しましょう。
手書きした型定義を使って、型定義ファイル群がアップデートされます。
具体的には rbs prototype rb で生成された型定義ファイルから、不完全な include 文が削除されます (rbs subtract によって手書きの型定義が優先されるため)。

なお、このケースでは列挙する型がすぐに判明したので、具体的な型が記述できましたが、
どういうデータを返すのかが思い出せない場合などは、以下のように untyped 型を指定しても構いません。
不完全な型定義になってしまいますが、エラーを回避することができます。

module CookieJar
  class AccessHistories
    include Enumerable[untyped]
  end
end

3rd party gem のメソッドをオーバーライドしている箇所の調整

自分のケースでは、もうひとつエラーに遭遇しました。具体的には以下のエラーです。

root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
/usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:616:in `define_method': sig/prototype/app/models/city.rbs:18:2...18:67: ::City.find_by has duplicated definitions in /app/.gem_rbs_collection/activerecord/7.0/activerecord.rbs:362:2...362:35 (RBS::DuplicatedMethodDefinitionError)
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:577:in `block in import_methods'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:56:in `block in each'
        from /usr/local/lib/ruby/3.1.0/tsort.rb:350:in `block (2 levels) in each_strongly_connected_component'
        from /usr/local/lib/ruby/3.1.0/tsort.rb:431:in `each_strongly_connected_component_from'
        from /usr/local/lib/ruby/3.1.0/tsort.rb:349:in `block in each_strongly_connected_component'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:73:in `each_value'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:73:in `tsort_each_node'
        from /usr/local/lib/ruby/3.1.0/tsort.rb:347:in `call'
        from /usr/local/lib/ruby/3.1.0/tsort.rb:347:in `each_strongly_connected_component'
        from /usr/local/lib/ruby/3.1.0/tsort.rb:316:in `each_strongly_connected_component'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:51:in `each'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:576:in `import_methods'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:257:in `block (2 levels) in build_singleton0'
        from <internal:kernel>:90:in `tap'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:225:in `block in build_singleton0'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:218:in `build_singleton0'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:299:in `block (2 levels) in build_singleton'
        from <internal:kernel>:90:in `tap'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:298:in `block in build_singleton'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:291:in `build_singleton'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:470:in `block in run_validate'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `each'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `run_validate'
        from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:137:in `run'
        from /usr/local/bundle/gems/rbs-3.1.0/exe/rbs:7:in `<top (required)>'
        from /usr/local/bundle/bin/rbs:25:in `load'
        from /usr/local/bundle/bin/rbs:25:in `<main>'
rails aborted!
Command failed with status (1): [rbs -Isig validate --silent...]
/app/lib/tasks/rbs.rake:36:in `block (2 levels) in <main>'
Tasks: TOP => rbs:validate
(See full trace by running task with --trace)

これは ActiveRecord の City モデルで .find_by メソッドをオーバーライドしている場合に発生しました。

rbs prototype rb コマンドは、対象のソースコードにメソッド定義を見つけると、自動的に型定義を生成してくれるのですが、
3rd party gem 内で定義されているメソッドをオーバーライドしている場合は、3rd party gem 側の型定義と prototype の型定義が重複してしまうため、エラーが発生してしまいます。

この問題に関しては今のところ正しい回避方法を見つけられていないことから、メソッドをリネームすることで回避しています。
この書き方をしているのは 2箇所だけで、重要な場面でなかったことが救いでした。
開発補助ツールのためにメインコードを書き換えるというのは少しもやっとしますが、今後得られるメリットは大きいと期待して、えいやっと書き換えてしまいました。

このエラーの対応方法をご存知でしたら Twitter ID: @tk0miya までご一報ください。

エラーが無くなるまで繰り返す

ここまで、自分の手元で発生した rbs validate のエラーの内容とその対応方法をご紹介しました。

  • 3rd party gem のダミークラス、ダミーモジュールの定義
  • Enumerable の型調整
  • 3rd party gem のメソッドをオーバーライドしている箇所の調整

rbs validate を実行し、出てくるエラーをひとつずつ解決していってください。
手元の中規模アプリでは、手順を整理しながら半日ほどで対応できました。
対応方法が整理された今であれば一時間以内で対応できる内容です。

最終的に、これらの対応をすることによって、rbs validate でのエラーは発生しなくなりました。

root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
root@f8da7af3f06e>

ここまで対応することで、ようやく自分で型定義を書く準備が整いました。

詳しい手順は次の記事で紹介しますが、ここからは型を書いて rbs:setup を実行、型を書いて rbs:setup を実行の繰り返しになります。

まとめ

この記事では、Ruby の型定義を書いていく下準備として環境を整備する方法をご紹介しました。

  • 最新の rbs パッケージのインストール
  • rbs:setup タスクのインストール
    • rbs prototype rb, rbs subtract, rbs validate の紹介
  • rbs:setup タスク初回実行後のエラー解決方法の紹介

次回は実際に型定義を書くことで、より katakata_irb の入力補完をリッチにする方法をご紹介します。