RBSとSteepに入門してみた

はじめに

イタンジ株式会社で物件管理くんの開発をしている河合です。最近、ミニシアターで「怪物」という映画を観てきました。作品の世界観と映画館の雰囲気が相まって、映画に没入できとてもいい体験になりました。

物件管理くんでは、フロントはTypeScript、バックエンドはRubyで開発を行なっています。最近チーム内でRubyにも型を導入しようという話になり、RBSとSteepを触ってみたため、今回はそれを記事にしようと思います。

型を導入するメリット

エラーの機械的検出

静的型検査によって、開発者が意図していない操作(型に関するエラー)を機械的に検出することができます。加えて型定義を変更すれば、静的型検査時に修正の必要な箇所が型エラーとして現れるため、修正箇所の特定が容易になります。

開発体験の向上

インターフェースとして型が定義されていれば、コードの可読性が向上し、リファクタリングも容易になります。

導入方法

ライブラリ

インストール

1.GemfileにRBSとSteepを追加し、ターミナルで$bundle installを実行しました。

gem "rbs"
gem "steep"

2.ターミナルで$steep initを実行し、Steepfileを作成しました。

D = Steep::Diagnostic

target :lib do
  signature "sig"
  check "lib"

  configure_code_diagnostics do |hash|
    hash[D::Ruby::MethodDefinitionMissing] = :warning
  end
end

configure_code_diagnosticsで、型検査のエラーレベルをカスタマイズすることができます。RBSで宣言されているにも関わらず、Rubyに実装されていないメソッドがあればwarningを出したいため、MethodDefinitionMissingを指定しています。

3.ターミナルで$steep checkを実行し、型検査が動作するかテストしました。

$steep check
 => No type error detected.

触ってみた感想

公称型、構造的部分型を使い分けながら、多くのケースで型付けすることができ、型の恩恵を十分に得ることができると思いました。ただし、動的なメソッドなど検査型することができないケースも存在するため、型検査に頼りすぎないよう注意しようと思います。

公称型

型にクラス名を指定すると、公称型によって型の互換性を検証します。RubyはTypeScriptと違い、オブジェクトが持つメソッドが動的に変化することがあるため、公称型を採用しているのではないかと思っています。

## rbs
class Cat
  attr_reader name: String

  def initialize: (String) -> void
end

class Dog
  attr_reader name: String

  def initialize: (String) -> void
end

class Object
  def put_name: (Cat) -> void
end

## ruby(クラスやメソッドの実装は省略)
dog = Dog.new("Pochi")
put_name(dog)

## ターミナル
$ steep check
=> Cannot pass a value of type `::Dog` as an argument of type `::Cat`

構造的部分型

クラスに関係なく、オブジェクトが特定のメソッドを持っていれば、型の互換性ありと判断したい場合があると思います。 そんな時は、Interfaceを用いることで、構造的部分型で型の互換性を検証することができます。

## rbs
interface _Foo
  def foo: () -> String
end

interface _Bar
  def bar: () -> String
end

class Foo
  include _Foo
end

class FooBar
  include _Foo
  include _Bar
end

class Object
  def put_foo_bar: (_Foo & _Bar) -> void
end

## ruby(クラスやメソッドの実装は省略)
put_foo_bar(Foo.new) # fooメソッドしか持っていないため型エラー
put_foo_bar(FooBar.new) # foo, barメソッドを持っているためOK

## ターミナル
$ steep check
 => Cannot pass a value of type `::Foo` as an argument of type `(::_Foo & ::_Bar)`

動的なメソッド

動的なメソッドに対しては型検査を行うことはできません。 @dynamic アノテーションを使うことで、動的なメソッドが実装されていることをSteepに検知させることはできます。

## rbs
class Sample
  def dynamic_method: () -> Integer
end

## ruby
class Sample
  # @dynamic dynamic_method
  define_method :dynamic_method do
    "dynamic_method"
  end
end

puts Sample.new.dynamic_method # 動的なメソッドの型検査は行われないため、型エラーにならない

## ターミナル
$ steep check
 => No type error detected. # 動的なメソッドが実装されていることをSteepが検知しているため、MethodDefinitionMissingにはならない

まとめ

Rubyに型を導入するにあたり、RBSとSteepを触ってみました。Rubyの動作を阻害せずに型の恩恵を十分に得ることができるため、Rubyに型を導入するメリットは大きいと思いました。その一方、実際のアプリケーションに導入するには、ライブラリの型情報の取得や型を書く労力をどう減らすかなどの課題があると思います。今後も型の導入に向けて勉強していきたいと思います。