こんにちは。ファインディでソフトウェアエンジニアをしているnipe0324です。
先日、愛媛県松山市で開催されていたRubyKaigi 2025 に参加してきました。
様々なセッションに参加し、他社のエンジニアと話す中で多くの刺激をうけました。特に印象深かったのは、Sansanさん、TwoGateさん、Timeeさんなどの企業がRubyの型を導入して運用していたことです。
本記事では、N番煎じですが、Findy転職のRailsプロジェクトにSorbetを入れて型を試してみました。
Rubyの型に興味を持っているけどなかなか試せていないという方の参考になれば嬉しいです。
Sorbetとは?
Sorbet(https://github.com/sorbet/sorbet) はRubyのコードに型注釈をつけて、型エラーを検出できるツールです。
StripeやShopifyなどの企業で利用されています。
Sorbetのメリット・デメリット
Sorbetの主なメリットとデメリットは次のとおりです。
メリット
- 型エラーを事前に検出できる
- コードの可読性と保守性の向上
- IDEのコード補完がより有効に使える
- リファクタリングが安全に行える
- ドキュメントとしての役割
デメリット
- 導入コストがかかる(コードの修正、型定義の作成)
- Rubyの動的な特性が一部制限される
- チーム全体に学習コストが発生する
- すべてのgemやライブラリが型に対応しているわけではない
Sorbetを使ったRubyのコード例
Sorbetを使うと、型をインラインで定義できます。
このコード例では、sig
を使って、greet
メソッドがString
型の引数を受け取ることを宣言しています。そのため、数値(Integer)を渡すと型エラーが検出されます。
# typed: true class User extend T::Sig sig {params(name: String).void} def greet(name) "Hello #{name}" end end User.new.greet("Tom") # OK - 文字列を渡している User.new.greet(3) # 型エラー - 数値を渡している
現時点でRubyの主要な型チェッカーとして「Sorbet」と「Steep」があります。また、型定義の書き方として「RBI(Ruby Interface)」と「RBS」があります。
現時点の組み合わせとして、「SorbetとRBI」、「SteepとRBS」というように型チェッカーと型定義を組み合わせ使います。
型導入のモチベーション
Findy転職のRailsアプリケーションでは、「GraphQL API」、「Interactor」、「Model」といったレイヤーで実装をしています。
Interactor層では、collectiveidea/interactor という gemを使ってビジネスロジックを実現しています。
1つの操作(ユーザー登録やデータ検索など)を独立した「インタラクション」として実装でき、責務を分割しやすい特徴があります。
課題:Interactorの入出力が不明確
Interactor gemの内部ではOpenStruct
を使ってデータの受け渡しをしています。つまり、どんな値でも自由に設定できる柔軟性がある反面、入出力として何があるのか不明確になりやすいです。
Interactorのサンプルコードで問題点を確認してみます。
# イメージ実装 # Interactorの実装 class CreateJobDescriptionInteractor include Interactor def call job_description = JobDescription.new(create_params) if job_description.save # contextには何でも入れられるため # Interactorの返り値を知るには実装を読む必要がある context.job_description = job_description else context.fail!(error_messages: job_description.errors.full_messages) end end private def create_params # contextにどんな値が入るかは呼び出し元を見る必要がある context.params.slice(:name, :description, :job_type) end end # 呼び出し元 # Interactorの中身を読まないと何が返されるか分からない result = CreateJobDescriptionInteractor.call(params: { title: '求人票', description: '求人票の内容', job_type: 'バックエンドエンジニア' }) if result.success? result.job_description # job_descriptionが返るのは実装を読まないと分からない else result.error_messages # error_messagesが返るのも実装を読まないと分からない end
この問題に対して、型を導入することで入出力を明確にし、コードの可読性と保守性を向上させることを目指しました。
検証した結果
Sorbetの公式ドキュメントを見ながら、GraphQL、Interactor、Modelに対して型を定義してみました。
結果としては、次のとおりです。
- ActiveRecordのモデルとGraphQLのAPIは
tapioca
を使うことで型定義(RBIファイル)をある程度自動生成できるため導入が簡単 - Interactorは、gemの特性上、型との相性が悪く、PORO(Plain Old Ruby Object)などの設計の変更の実施が必要そう
検証の実施内容
Sorbetのセットアップ
SorbetのGetting Started (https://sorbet.org/docs/adopting) を見ながらセットアップをしました。
まず、Gemfileに必要なgemを追加してbundle install
を実行します。
# Gemfile gem 'sorbet', group: :development gem 'sorbet-runtime' gem 'tapioca', require: false, group: [:development, :test]
次に、Tapiocaを使ってSorbetを初期化します。このコマンドでsorbet
ディレクトリが作成され、プロジェクト内のGemに対して自動的に型定義(RBIファイル)が生成されます。
$ bundle exec tapioca init
Sorbetによる型チェックを実行します。初回は多くの型エラーが出るので、修正していきます。
$ bundle exec srb tc
型エラーを修正して、型チェックが成功したら初期セットアップは完了です 👏
$ bundle exec srb tc No errors! Great job.
ActiveRecordのモデルに型を追加
tapioca dsl
コマンドを使うことで、ActiveRecordモデルやGraphQL-RubyのDSL(Domain Specific Language、特定領域向け言語)から自動的に型定義ファイル(RBIファイル)が作成できます。
$ bundle exec tapioca dsl Loading DSL extension classes... Done Loading Rails application... create sorbet/rbi/dsl/skill.rbi create sorbet/rbi/dsl/user.rbi create sorbet/rbi/dsl/job_description.rbi create sorbet/rbi/dsl/xxxx.rbi ...
例えば、JobDescriptionモデルがある場合、次のようなRBIファイルが自動的に作成されます。これによりモデルのプロパティに型情報が付与されます。
class JobDescription # ... sig { returns(::String) } def title; end sig { params(value: ::String).returns(::String) } def title=(value); end sig { returns(T::Boolean) } def title?; end sig { returns(T.nilable(::String)) } def title_before_last_save; end
では、実際に型チェックが機能するか検証してみます。
# typed: true
をファイルの上部に追加し、型チェックの対象にします。さらに、型エラーの動作確認のためわざと不正な値を設定してみます。
# frozen_string_literal: true # typed: true class JobDescription < ApplicationRecord def type_check_error_method self.title = 1 # String型のプロパティにInteger型を設定(エラーになるはず) end end
Sorbetを実行すると、予想通り型エラーが検出されました。👏
$ bundle exec srb tc app/models/job_description.rb:6: Assigning a value to value that does not match expected type String https://srb.help/7002 6 | self.title = 1 ^ Got Integer(1) originating from: app/models/job_description.rb:6: 6 | self.title = 1 ^ Errors: 1
GraphQLに型を追加
graphql-ruby を利用しているプロジェクトでは、GraphQLの型定義もtapioca dsl
コマンドで自動生成されます。
# 自動生成されるRBIファイルのイメージ class CreateJobDescriptionMutation sig { params(title: ::String, company_id: ::Integer).returns(T.untyped) } def resolve(title:, company_id:); end end
ActiveRecordモデルと同様に、型定義に違反した実装をすると型エラーが発生します。これによりGraphQLのリゾルバーやミューテーションにも型安全性を導入できます。
ただし、自動生成されたRBIファイルでは戻り値がT.untyped
(型のない状態)になることがあるため、具体的な型を指定していく必要があると感じました。
Interactorに型を追加
Interactorでは、型定義をうまく行うことができませんでした。
Interactor gem で定義されているcontext
が、OpenStruct
を使っておりデータが柔軟に設定できるがゆえに型定義がうまくできませんでした。
# frozen_string_literal: true # typed: true class CreateFooInteractor extend T::Sig include Interactor # contextのアクセスのための型定義 # 上手く定義できず、`T.untyped`(型のない状態)になっている sig { returns(T.untyped) } attr_reader :context def call foo = Foo.new(create_params) if foo.save context.foo = foo else context.fail!(error_messages: foo.errors.full_messages) end end private def create_params context.params.slice(:title, :company_id) end end
改善案としては、PORO (Plain Old Ruby Object) による実装に変えて、入出力の型を明確にすることで、型定義を記載するという方法が考えられます。
また、https://github.com/maxveldink/sorbet-result にあるような RustのResult
型に似た実装を導入するのも効果的です。Result型は「成功」か「失敗」のどちらかの状態を表現するもので、型安全な方法で結果を扱えるようになります。
型でガチガチにするとRubyの良さが失われてしまう懸念もあるため、段階的に導入しつつバランスを見ていく必要があります。
型を試してみた所感
今回既存のRailsプロジェクトでSorbetによる型を試しに導入しました。
感想としては、検討事項は他にもありますが、前向きに型導入を進めていこうと思いました。
- ActiveRecordやGraphQLにほぼ自動的に型定義を追加できたり、段階的に型チェックを有効化できるので小さく始めやすい
- ローカル開発やCIで型チェック、GraphQLやテーブルスキーマ変更時の型更新のフローを整備する必要がある
- SorbetとSteepのどちらが良いかは好みによるので検討は必要がある
- など
最後に
ファインディでは、一緒にRubyやRailsの開発をしてくれる仲間を募集しています。 興味のある方は、ぜひこちらからチェックしてみてください! herp.careers
また、2025/5/13(火)に、「After RubyKaigi 2025〜ZOZO、ファインディ、ピクシブ〜」として、3社合同でRubyKaigi 2025の振り返りを行います。
オンライン・オフラインどちらもありLTやパネルディスカッションなどコンテンツが盛りだくさんなのでぜひご参加ください!!