はじめに
イタンジのエンジニアリングマネージャの田渕です。
昨年末にリリースされたRuby3では、静的型検査の機能が追加されました。
早速、実際の新規Railsプロダクトの開発に活用してみたところ、多くの利点を感じました。
現場で使われている例はまだ多くないと思うので、実際の設定ファイル等と知見を共有します!
言語・ライブラリのバージョン等
- ruby 3.0.1
- rails 6.1.3.1
- rbs 1.1.1
- steep 0.43.1
- yard 0.9.26
- sord 3.0.1
- gem_rbs_collection 3c17afb4b536757d04b33f8715072a5f5cfb0f53(バージョンがないのでコミットハッシュを書いています)
方針
もともと型検査とは関係なく、yardを用いて型を含めてドキュメントを書くようにしていました。
そこで注釈した型を最大限活かせるように、sordというライブラリを用いて、yardのドキュメントからrbsファイルを生成することにしました。
これによって、設定ファイルや依存ライブラリを追加するのと、多少コードを修正する程度の工数で、静的型検査の恩恵を受けることできました。
導入方法
ライブラリのインストール
group :development, :test do gem 'sord', require: false gem 'steep', require: false gem 'webrick', require: false # yardの導入のために必要 gem 'yard', require: false end
Gemfileに以上のライブラリを追加し、bundle install
を実行します。
また、以下のコマンドでgem_rbs_collectionを導入できます。
git submodule add https://github.com/ruby/gem_rbs_collection.git vendor/rbs/gem_rbs_collection
実際のSteepfile
target :app do check 'app/**/*.rb' ignore 'path/to/untyped_files.rb' signature 'sig' repo_path 'vendor/rbs/gem_rbs_collection/gems' library 'activemodel' library 'activesupport' library 'actionpack' library 'activerecord' library 'actionview' library 'forwardable' library 'logger' library 'mutex_m' library 'monitor' library 'railties' library 'singleton' library 'tsort' # typing_options :strict end
標準ライブラリは必要に応じて追加しています。
基本的には全てのファイルを型検査しているのですが、どうしても型がつけづらい部分(コストとリターンが釣り合わないもの)に関しては、ignoreをしています。
また、typing_optionsについては、現状ではドキュメントがないので、steepの実装から予想して書いています。
本来はコメントアウトを外したほうが望ましいのですが、実装の修正量が多くなるため、見送っています。
型検査を行うコマンド
sord sig/defs.rbs && steep check
このように、sordからrbsファイルを生成し、steepでの型検査を行います。

実装例
簡単なEnumを表すクラスの例で説明します。
class Enum ALL = { 1 => 'one', 2 => 'two', 3 => 'three' }.freeze # @param num [Integer] # @return [String, nil] def self.find_by_number(num) ALL[num] end # @param num [Integer] # @raise [StandardError] # @return [String] def self.find_by_number!(num) find_by_number(num) || raise end end num = 5 str = 'hoge' Enum.find_by_number!(num).upcase Enum.find_by_number(num)&.upcase Enum.find_by_number(num).upcase # [error] Type `(::String | nil)` does not have method `upcase` Enum.find_by_number(str) # [error] Cannot pass a value of type `::String` as an argument of type `::Integer` Enum.find_by_number # [error] Cannot find method `find_by_number` of type `singleton(::Enum)` with compatible arity
nilのハンドリングのし忘れや、引数の書き忘れ、引数の型の間違いを正しく検知してくれていることがわかります。
ただし、この場合だとALLという定数がuntypedになるため、@returnでのannotationを誤った場合にはエラーが出てくれません。(現状のsordでは定数に型がつけられないためです)
例としては最適ではないかもしれませんが、書き方の参考にしていただければと思います。
ライブラリ由来のクラス等が解決できない場合
これだけではライブラリ由来のクラスが解決できない場合があります。
その場合は、型定義ファイルを追加したりし、足りていない定義を追加していきます。
また、メソッド等も見つからない場合にも、自分もrbsファイルを書いていく必要があります。
例えば、現状のgem_rbs_collectionの定義では、Controller内でheadersにアクセスできなかったため、sig以下に
class ActionController::API def headers: () -> Hash[untyped, untyped] end
のようなrbsファイルを書いています。 (本来はuntypedではないのですが、この型定義の生存期間はライブラリが対応するまでなので、最低限動く範囲で型をつけています)
型をより活かすための設計
ライブラリのrbsファイルはほとんど存在しないのが現状です。
なので、ライブラリの型はuntyped(TypeScriptのanyに近い)になってしまいます。
自分でrbsファイルを書くのはなかなか骨が折れるので、妥協も必要だと感じています。
このように、すべてを型で守れるわけではなくなってくると、守りたい部分を守れるような設計が非常に重要になってきます。
具体的には、ドメイン部分でライブラリに極力依存せず、ピュアなRubyオブジェクトで実装することを意識すると、型安全になりやすいようです。
型検査による効果
定量的な値は出せないのですが、テストで漏らしていたnilの考慮漏れによるバグを実際に防げた例があり、実際に手戻りを防げています。
感覚としては、JavaScriptからの移行を行っている最中のTypeScriptに近く、部分的にでも型で守られている安心感があり、開発体験が非常に良かったです。
また、大事なところに型がつくように実装すると、自然と設計も良くなると思います。
ピュアなRubyオブジェクトで実装しているとテストも実装しやすいため、型以外の意味でも安全なプログラミングがしやすくなった感覚がありました。
おわりに
まだライブラリの型定義が多くないため、すべてを型安全にすることは難しいようです。
しかし、テストを補う形で型検査ができるため、従来より安全性を高めることができ、開発効率が上がっています。
今後も、安全かつ効率の良い開発を行うための工夫を継続し、より良いソフトウェアを作っていこうと思います。
また、知見が溜まったら共有していきたいと思います!
今後ともよろしくお願いします。