Rubyの型チェッカーSorbetを導入して良かったこと・悪かったこと
この記事はPharmaXアドベントカレンダー2022 7日目の記事です。
PharmaXでエンジニアをしている加藤(@tomo_k09)です。
以前の記事でRubyの型チェッカーであるSorbetを導入したと書きました。
Sorbetを本格的に導入して半年以上経過したので、本記事ではSorbetを導入して良かったこと・悪かったことについて振り返ろうと思います。
Sorbetを導入して良かったこと
まずは良かったことからご紹介します。
学習コストが低い
1つ目が学習コストが低いという点です。
代表的なRubyの型ライブラリとしては、SteepとSorbetがありますが、Sorbetの方が導入はしやすいかと思います。
というのも、SorbetはRubyの文法で記述できるためです。
sigというメソッドを使うことにより、引数はstring、返り値はintegerというように型の定義が可能です。
# sorbet example
# typed: true
extend T::Sig
sig {params(name: String).returns(Integer)}
def main(name)
puts "Hello, #{name}!"
name.length
end
ちなみにSteepで型を定義する場合はこんな感じになります。
RubyっぽいけどRubyとは少し異なる書き方をしなければなりません。
# Steep example
class Person
@name: String
@contacts: Array[Email | Phone]
def initialize: (name: String) -> untyped
def name: -> String
def contacts: -> Array[Email | Phone]
def guess_country: -> (String | nil)
end
Rubyでも型の恩恵を受けられる
「このメソッドからはどんな値が返ってくるのだろう?」
「この引数は名前からしてstringが返ってきそうだけど、間違ってないよな?」
Rubyの開発者であれば、1度はこんなことを思ったことがあると思います。
Sorbetはコードと型を一緒に管理できるため、Rubyのアプリケーション開発にありがちなこういった悩みから解放してくれました。
以下のサンプルコードを見ていただくと、パッと見でどんな型の引数・返り値なのかが分かるかと思います。
# Example
class UserEntity
sig { returns(Integer) }
attr_reader :id
sig { returns(String) }
attr_reader :first_name, :last_name,
sig { params(id: Integer, first_name: String).void }
def initialize(id:, first_name:, last_name:)
@id = id
@first_name = first_name
@last_name = last_name
end
end
class UserRepository
sig {params(id: Integer).returns(UserEntity)}
def find_user(id:)
user = User.find(id)
UserEntity.new(id: user.id, first_name: user.first_name, last_name: user.last_name)
end
end
end
またプログラム実行前に
「ここtypoしてるよ」
「変数を宣言してるけど、どこにもこの変数が使われてないぞ」
「引数の型、間違ってるから直して」
と注意してくれるのも非常にありがたかったです。
Sorbetを導入して悪かったこと
逆にSorbetを導入してみて大変だったことについて。
コードが冗長になる
引数、戻り値の型をひとつひとつ定義する必要があるため、コードを書く量がとても増えます。
先ほどのサンプルコードを改めて確認してみましょう。
こちらがsorbetで型をつけているパターン。
# Example
class UserEntity
sig { returns(Integer) }
attr_reader :id
sig { returns(String) }
attr_reader :first_name, :last_name,
sig { params(id: Integer, first_name: String).void }
def initialize(id:, first_name:, last_name:)
@id = id
@first_name = first_name
@last_name = last_name
end
end
class UserRepository
sig {params(id: Integer).returns(UserEntity)}
def find_user(id:)
user = User.find(id)
UserEntity.new(id: user.id, first_name: user.first_name, last_name: user.last_name)
end
end
end
そしてこちらがsorbetがないバージョン。
パッと見でコード量が少ないのが分かるかと思います。
# Example
class UserEntity
attr_reader :id, :first_name, :last_name
def initialize(id:, first_name:, last_name:)
@id = id
@first_name = first_name
@last_name = last_name
end
end
class UserRepository
def find_user(id:)
user = User.find(id)
UserEntity.new(id: user.id, first_name: user.first_name, last_name: user.last_name)
end
end
正直、↓のように書けたらなぁと思いました。
class UserEntity
def initialize(id: Integer, first_name: String, last_name: String)
@id = id
@first_name = first_name
@last_name = last_name
end
end
まぁRubyのライブラリなので、このように冗長になってしまうのは仕方がないかもしれないですが。
継承の型が保証されない
例えば、Parentというファイルがあって、それを継承したChildというファイルがあったとしましょう。
この場合、親で定義した型が子に引き継がれることが保証されていません。
Sorbetのリファレンスには「これは親のメソッドだよ」と100%保証してくれないとはっきり書いてあります。
RBIファイルのメンテナンスが修正がしんどい
SorbetはRBIファイルを使って型注釈をしています。
つまり、このRBIファイルを通して、
- この定数にはString型が入っている
- このメソッドは引数としてIntegerが渡されて、String型の値を返す
といったことを判定するわけです。
このRBIファイルが曲者で、たびたび謎のエラーを吐き出して、PharmaXのエンジニアたちを苦しめていました。。。
幸いにもSorbetのリファレンスは充実しているので、直そうと思えば直すことはできるのですが、RBIファイルの扱いに慣れるには、それなりに時間を要しそうです。
終わりに
Sorbetには使い勝手の悪い面もあるのですが、型をつけることによって事前にエラーの芽を摘み取りやすくなったので、総合的には導入して良かったなと個人的には思っています。
Sorbet特有の作法について多少学ぶことが必要ですが、比較的使いやすいライブラリだと思うので、ぜひ試してみてください。
PharmaXアドベントカレンダー2022では、2022年12/25日まで技術的な話から組織の話まで幅広い記事が公開される予定です。
引き続き、ぜひよろしくお願いいたします!
PharmaXでは定期的にテックイベントをオンラインで開催しています!
2023年1月は、金融業界・花き業界・薬局業界という異なる業界でDX推進をリードするスタートアップ企業3社が集まり、それぞれのオペレーションを担っているドメインエキスパートとプロダクト開発チームがどのように連携しながらプロダクトや開発組織を作っているのかについてディスカッションします。
ぜひお気軽にご参加ください。