Ruby3の静的型検査を活用して、新規プロダクト開発の開発効率を向上させた話

はじめに

イタンジのエンジニアリングマネージャの田渕です。

昨年末にリリースされたRuby3では、静的型検査の機能が追加されました。
早速、実際の新規Railsプロダクトの開発に活用してみたところ、多くの利点を感じました。

現場で使われている例はまだ多くないと思うので、実際の設定ファイル等と知見を共有します!

言語・ライブラリのバージョン等

方針

もともと型検査とは関係なく、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での型検査を行います。

f:id:ktabuchi:20210506203601p:plain
型検査が成功した時の画像

実装例

簡単な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オブジェクトで実装しているとテストも実装しやすいため、型以外の意味でも安全なプログラミングがしやすくなった感覚がありました。

おわりに

まだライブラリの型定義が多くないため、すべてを型安全にすることは難しいようです。
しかし、テストを補う形で型検査ができるため、従来より安全性を高めることができ、開発効率が上がっています。

今後も、安全かつ効率の良い開発を行うための工夫を継続し、より良いソフトウェアを作っていこうと思います。
また、知見が溜まったら共有していきたいと思います!

今後ともよろしくお願いします。