Rubyプロジェクトに型を導入してみた
はじめに
Rubyは動的型付け言語として知られていますが、実はRBSという言語を利用することでRubyの型を記述することができるのをご存知でしょうか? RubyKaigi2025でも型についてのセッションが多くあり、Ruby界隈では熱い話題です。
本記事ではsteepとrbs-inlineを活用して、Rubyの型体験をしてみたいと思います。
RBS(型)導入のメリット・デメリット
なぜRubyに型を導入するのかを整理したいと思います。特に最近では、AI Agent や GitHub Copilot のような生成AIの補助ツールの存在が、型の重要性をより高めていると感じています。
メリット
-
実行前に型の不整合を検出できる
- ロジックのエラーがある時は、その型の不整合が伴うことが多い
-
型宣言はドキュメントとしての性質を持つ
- 生成AIの恩恵を受けやすくなる
-
IDEの補助を受けられる
- LSPで型エラーやメソッド補完などを活用できる
デメリット
-
型が複雑になりがち
- ジェネリクスなどで型が複雑になりがち
-
型をつけるコストが高い
- 型はなくても動くものではある
- メタプロに対応していない
- gemに型がついていない場合がある(gem_rbs_collectionの不足)
-
型が思想に反する場合がある
- DHHは静的型付けとRubyが相容れないと主張している(Programming types and mindsets
より)
- DHHは静的型付けとRubyが相容れないと主張している(Programming types and mindsets
型を適用するプロジェクト
以前のブログで紹介したマインスイーパのプロジェクトに型を導入したいと思います。部分的にメタプロを使っている箇所があるので、RBSのデメリットを体験できるかなと思います。
サンプルコード
型導入前
型導入後
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
で指定します。
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機能を利用できます。
-
error
やwarning
の警告表示 - ホバーによるメソッド呼び出しや変数の型の表示
- メソッドや変数の補完
CIで型チェックを実行する
rbs-inlineでrbsファイルを生成し、steepで型チェックをCIで実行するようにしました。これによって、rbs-inlineとrbsファイルの一貫性が保証されます。
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に文法が記載されているので参考にしましょう。
# 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
# 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_coordinations
をeach
メソッドで回して、2次元配列にそのインデックスを渡しているコードです。
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処理も実装しました)
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
を明示的に記載する必要が出ています。これはメタプロの内部処理が露出している状態であると言えます。
module Domains
# @rbs inherits Base
class Cell < Base
# @rbs!
# extend Base::ClassMethods
with_validation :toggle_flag, :reveal
end
end
また、def_delegators
を利用してメソッドを委譲している箇所があります。しかし、RBSには委譲を表現できる書き方がなかったため、あたかも元から静的にメソッドが定義されているかのように型をつけました。少し直感的な解決方法ではないですよね。
# @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との連携なども含めて、より深い活用方法を模索していきたいと思います。
参考文献
Discussion