RubyKaigi 2025レポート:FindyのRailsプロジェクトでSorbetの型チェックを試してみた

こんにちは。ファインディでソフトウェアエンジニアをしているnipe0324です。

先日、愛媛県松山市で開催されていたRubyKaigi 2025 に参加してきました。

様々なセッションに参加し、他社のエンジニアと話す中で多くの刺激をうけました。特に印象深かったのは、Sansanさん、TwoGateさん、Timeeさんなどの企業がRubyの型を導入して運用していたことです。

本記事では、N番煎じですが、Findy転職のRailsプロジェクトにSorbetを入れて型を試してみました。

Rubyの型に興味を持っているけどなかなか試せていないという方の参考になれば嬉しいです。

Sorbetとは?

Sorbet(https://github.com/sorbet/sorbet) はRubyのコードに型注釈をつけて、型エラーを検出できるツールです。

StripeやShopifyなどの企業で利用されています。

Sorbetを利用している企業例

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の型チェッカー

現時点でRubyの主要な型チェッカーとして「Sorbet」と「Steep」があります。また、型定義の書き方として「RBI(Ruby Interface)」と「RBS」があります。
現時点の組み合わせとして、「SorbetとRBI」、「SteepとRBS」というように型チェッカーと型定義を組み合わせ使います。

型導入のモチベーション

Findy転職のRailsアプリケーションでは、「GraphQL API」、「Interactor」、「Model」といったレイヤーで実装をしています。

Findy転職のRailsアプリケーションのレイヤー抜粋

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のAPItapiocaを使うことで型定義(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やパネルディスカッションなどコンテンツが盛りだくさんなのでぜひご参加ください!!

pixiv.connpass.com