💣

Rubyプロジェクトに型を導入してみた

に公開

はじめに

Rubyは動的型付け言語として知られていますが、実はRBSという言語を利用することでRubyの型を記述することができるのをご存知でしょうか? RubyKaigi2025でも型についてのセッションが多くあり、Ruby界隈では熱い話題です。

本記事ではsteeprbs-inlineを活用して、Rubyの型体験をしてみたいと思います。

RBS(型)導入のメリット・デメリット

なぜRubyに型を導入するのかを整理したいと思います。特に最近では、AI Agent や GitHub Copilot のような生成AIの補助ツールの存在が、型の重要性をより高めていると感じています。

メリット

  1. 実行前に型の不整合を検出できる
    • ロジックのエラーがある時は、その型の不整合が伴うことが多い
  2. 型宣言はドキュメントとしての性質を持つ
    • 生成AIの恩恵を受けやすくなる
  3. IDEの補助を受けられる
    • LSPで型エラーやメソッド補完などを活用できる

デメリット

  1. 型が複雑になりがち
    • ジェネリクスなどで型が複雑になりがち
  2. 型をつけるコストが高い
    • 型はなくても動くものではある
    • メタプロに対応していない
    • gemに型がついていない場合がある(gem_rbs_collectionの不足)
  3. 型が思想に反する場合がある

型を適用するプロジェクト

以前のブログで紹介したマインスイーパのプロジェクトに型を導入したいと思います。部分的にメタプロを使っている箇所があるので、RBSのデメリットを体験できるかなと思います。

https://zenn.dev/yuhi_junior/articles/062cf4f30b083d

サンプルコード

型導入前

https://github.com/Yuhi-Sato/minesweeper/tree/37eb0932450a4274ac7e0a21f55f282f6df01d52/domains

型導入後

https://github.com/Yuhi-Sato/minesweeper/tree/main/domains

steep・rbs-inlineの導入

Gemfileに以下を追加

+ gem 'steep', require: false
+ gem 'rbs-inline', require: false
$ bundle install

gemの型情報が書かれたgem_rbs_collectionをセットアップします。生成される.gem_rbs_collectionはgit管理から外すことが勧められています(Using RBS from gem_rbs_collectionより)。

$ rbs collection init

以下のコマンドでSteepfileが生成されます。

$ bundle exec steep init

Steepfileを以下のように編集します。Forwardableを利用しているのでlibraryで指定します。

Steepfile
target :domains do
  signature "sig/domains"   # rbsのディレクトリを指定
  check "domains"           # 型をチェックするディレクトリを指定

  ignore "domains/base.rb"  # 型をチェックしないファイルを指定

  library "forwardable"     # 利用するライブラリを記述
end

こちらにsteepの型検査の設定について記載されています。基本的にはdefaultテンプレートで良いと思います。strictテンプレートを利用すると、型アノテーションのチェックがhintからerrorに変更され、空配列の型チェックブロックの型チェックブロック引数渡しのチェックなどがwargingからerrorに変更されます。

VSCodeの拡張機能

steepの拡張機能をダウンロードすると以下のようなLSP機能を利用できます。

  • errorwarningの警告表示
  • ホバーによるメソッド呼び出しや変数の型の表示
  • メソッドや変数の補完

https://marketplace.visualstudio.com/items?itemName=soutaro.steep-vscode

CIで型チェックを実行する

rbs-inlineでrbsファイルを生成し、steepで型チェックをCIで実行するようにしました。これによって、rbs-inlineとrbsファイルの一貫性が保証されます。

https://github.com/Yuhi-Sato/minesweeper/blob/cdf17d3769e46b1f65144f79f402b3bceee0820f/.github/workflows/steep.yml

rbs-inlineで型を記述してみる

ではrbs-inlineを活用してdomainsディレクトリのrubyファイルに型をつけていきます。

ディレクトリ構成
.
├── domains
│   ├── base.rb
│   ├── cell.rb
│   ├── cell_with_neighbors.rb
│   ├── grid_cells.rb
│   ├── grid_cells_factory.rb
│   ├── minesweeper.rb
│   ├── position.rb
│   └── validators
│       ├── base.rb
│       ├── cell_validator.rb
│       ├── grid_cells_validator.rb
│       └── position_validator.rb
└── sig
    └── domains

Syntax guideに文法が記載されているので参考にしましょう。

domains/cell.rb
# rbs_inline: enabled

module Domains
  # @rbs inherits Base
  class Cell < Base
    # @rbs @bomb: bool
    # @rbs @flag: bool
    # @rbs @revealed: bool

    # @rbs (bomb: bool) -> void
    def initialize(bomb:)
      @bomb = bomb
      @flag = false
      @revealed = false
    end

    # @rbs () -> bool
    def bomb?
      @bomb
    end
  ...
end

以下のコマンドでsig/domainsディレクトリに対応するrbsファイルが生成されます。

$ bundle exec rbs-inline domains --output=sig
sig/domains/cell.rbs
# Generated from domains/cell.rb with RBS::Inline

module Domains
  # @rbs inherits Base
  class Cell < Base
    @bomb: bool

    @flag: bool

    @revealed: bool

    # @rbs (bomb: bool) -> void
    def initialize: (bomb: bool) -> void

    # @rbs () -> bool
    def bomb?: () -> bool
  ...
end

最後に型をチェックします。

$ bundle exec steep check

型をつけてみた感想

潜在的なエラーを防ぐことができた

メリットで述べたように型をつけることで潜在的なエラーが発生するコードを実際に検出することができました。

以下のコードは2要素の2次元配列であるneighbor_coordinationseachメソッドで回して、2次元配列にそのインデックスを渡しているコードです。

domains/grid_cells_factory.rb
neighbor_coordinations.each do |nx, ny|
  grid_cells[ny][nx]
end

このコードをsteepで型チェックすると以下のようなerrorが検出されます。ここでは、nx(::Integer | nil)型であり、[]メソッドにnilを渡すことができないためエラーが発生しています。

Cannot find compatible overloading of method `[]` of type `::Array[::Array[::Domains::CellWithNeighbors]]`
Method types:
  def []: (::int) -> ::Array[::Domains::CellWithNeighbors]
        | (::int, ::int) -> (::Array[::Array[::Domains::CellWithNeighbors]] | nil)
        | (::Range[(::Integer | nil)]) -> (::Array[::Array[::Domains::CellWithNeighbors]] | nil)Ruby::UnresolvedOverloading

以下のように変数に対して型定義を追加すると、型チェックが通るようになります。
(neighbor_coordinationsメソッドにnilになる場合のraise処理も実装しました)

domains/grid_cells_factory.rb
neighbor_coordinations.each do |nx, ny|
  # @type var nx: Integer
  # @type var ny: Integer
  grid_cells[ny][nx]
end

メタプロ周り

デメリットで述べたようにRBSはメタプロには対応していません。メタプロによって生えるメソッドなどは生えた側に静的に型をつける必要があります。意識させないようにメタプロを利用しているのに、型をつけるという形で意識せざる終えなくなってしまいます。

元のコードではBaseクラスを継承した際、内部で動的にextend Base::ClassMethodsを実行し、with_validationメソッドを利用できるようにしています。ここでは内部で実行しているextendを明示的に記載する必要が出ています。これはメタプロの内部処理が露出している状態であると言えます。

domains/cell.rb
module Domains
  # @rbs inherits Base
  class Cell < Base
    # @rbs!
    #   extend Base::ClassMethods
    with_validation :toggle_flag, :reveal
  end
end

また、def_delegatorsを利用してメソッドを委譲している箇所があります。しかし、RBSには委譲を表現できる書き方がなかったため、あたかも元から静的にメソッドが定義されているかのように型をつけました。少し直感的な解決方法ではないですよね。

domains/cell_with_neighbors.rb
# @rbs!
#   def bomb?: () -> bool
#   def empty?: () -> bool
#   def flag?: () -> bool
#   def revealed?: () -> bool
#   def toggle_flag: () -> void
#   def reveal: () -> void
def_delegators :@base, :bomb?, :empty?, :flag?, :revealed?, :toggle_flag, :reveal

また、以下のようにrbs-inlineの型はそのままで、委譲するメソッドを減らしても型チェックが通ってしまいます。メタプロによって定義されるメソッドと型の一貫性が担保されていません。

# @rbs!
#   def bomb?: () -> bool
#   def empty?: () -> bool
#   def flag?: () -> bool
#   def revealed?: () -> bool
#   def toggle_flag: () -> void
#   def reveal: () -> void
- def_delegators :@base, :bomb?, :empty?, :flag?, :revealed?, :toggle_flag, :reveal
+ def_delegators :@base, :bomb?

細かい感想

  • rbs-inlineの文法はTSライクなのですぐ慣れそう

  • Cursor Tabでrbs-inlineの補完も結構効く

  • neighbors.count(&:bomb?)のような:&の記法も型チェックできて感動

  • DataやStructもサポートされている

  • rbsファイルは分離されているのですぐに外せる

  • rbs-inlineのコメントも@rbsがついているのですぐに外せる

  • steep拡張機能で定義元へジャンプできる

  • rbs-inlineのコメントへのLSPが欲しい

  • 型アサーションができるがコメント形式なので右端にしか書くことができない。TSのasのように書きたい

最後に

本記事ではRubyプロジェクトに型を導入してみました。型を書いた時間は1日程度だったのであまり時間をかけずに型をつけることができました。メタプロ周りは厳しいものの、個人的には思っていたよりも良い型体験ができたので選択肢の1つとしてもっと広まってもいいのかなと思いました。

今回は外部gemを利用していないのですが、実際の運用に際してはrbs_gem_collection上に型が存在しないgemが多く存在します。こちらの資料にもある通り、パブリックなインターフェースにのみ型を付けるという運用になりそうです(更新への対応が大変そうですが。。。)

今後はAI Agentとの連携なども含めて、より深い活用方法を模索していきたいと思います。

参考文献

https://logmi.jp/main/technology/330373
https://findy-code.io/engineer-lab/soutaro
https://kaigionrails.org/2022/talks/yamashun/

Discussion