🌶️

Railsチュートリアルのsample_appに型を導入

2023/02/22に公開

ふーが といいます。普段は Ruby on Rails を利用したアプリケーション開発をしているプログラマです。

この記事は Rails アプリケーションへの型導入を試してもらうことを目的とした記事です。主に Ruby on Rails チュートリアル (以下「Rails チュートリアル」と呼称)で学ばれた方向けに「静的型とはなにか」「Ruby での型の扱い」「Rails アプリケーションへの型導入方法」を知っていただくきっかけになればうれしく思います。

この記事では実際に手を動かしながら Rails アプリケーションに型を導入する手順を体験できます。題材として Rails チュートリアルの sample_app を使用させていただいています。

前提

型を導入するにあたり前提となる部分を簡単にご紹介します。「型」と聞いてイメージがつく方はこの段落は飛ばしていただいてかまいません。

型とは何か

型は「データの種類」のことです。
たとえば変数に入っているデータを指して「この変数の型は Integer(数値)だよね」とか「このメソッドの戻り値は文字列だから String だね」といった感じで使います。
この「型」によって使用できるメソッドが決まったりするわけなのですが、Ruby では初学者の方が初めから型を意識してプログラミングをする必要はありません。それは Ruby が「動的型付け言語」だからです。コード上で型を宣言しなくとも、コードの実行時に型が解釈されて動作します。

一方「静的型付け言語」では、コードを書く際にプログラマが変数やメソッドに対する型宣言をします。

静的型付け言語である C と動的型付け言語である Ruby のコードを比べると次のような違いがあります。

#include<stdio.h>

int main (void) {
  int i = 1;  // int の部分が型宣言
  printf("%d\n", i);
}
i = 1 # 型宣言はない
puts i

ここではどちらが良い悪いではなく、「Ruby は動的型付け言語だが、最近静的型を扱える機能が導入された」ということを知っていただきたいです。また、上記の C 言語の例ではプログラム中に型宣言が書かれていますが、Ruby で静的型を書く場合は RBS という別ファイルに記述します。この辺りは後ほど実際に書いて試してみます。

型があることで何がうれしいのか

動的型付け言語である Ruby でなぜ型を扱えるようになったのでしょうか。

その理由の一つとして、型情報があることによって「開発者体験が向上する」ことが挙げられます。具体的には、バグの未然防止に繋がる、コーディングの際の入力補完の精度が上がる、エディタや IDE のコードジャンプ機能の精度が上がる、などがあります。また、メソッドの定義元を見るだけで引数や戻り値の型が一目でわかるため、(型を知りたいだけなら)メソッドの処理を追う必要がなくなります。

JavaScript に対する TypeScript であったり、動的型付け言語である PHP や Python でも静的型が扱えるようになったりしています。

Ruby もその流れに乗って静的型を導入した…と思われがちですが、実は Ruby の目指す型との付き合い方は独自の路線だったりします。もし興味があれば Ruby の生みの親である まつもとゆきひろ さんによる以下の動画などをご覧ください。

https://www.youtube.com/watch?v=D_dp-I5zS7c

Rails アプリケーションでの型の扱い

Ruby は 2020 年 12 月にリリースされた Ruby 3.0 から静的型を扱うライブラリが標準添付されるようになりました。現状まだ Rails アプリケーションへの型導入や運用事例が少ないため、ベストプラクティスは存在しません。今回は Rails チュートリアルの sample_app への型導入を通じて、Ruby での型定義方法や Rails アプリケーションに型導入する雰囲気を感じ取っていただけると良いと思っています。

手前味噌ですが Kaigi on Rails 2022 で型を導入した Rails アプリケーションの運用についてなどを発表しましたので興味のある方は動画をご覧ください。

https://www.youtube.com/watch?v=6QjrKkK-4pY

ほぼ同内容のブログ記事も書いていますのでそちらもあわせてどうぞ。

https://blog.agile.esm.co.jp/entry/example-of-introducing-and-using-rbs

おことわり

Rails チュートリアルの Rails 6 対応版のコードをもとにした記事になります。2023 年 2 月現在 Rails チュートリアルの最新版は Rails 7 対応のものですが、この記事で使用する gem が Rails 7 では動作しない部分があったためです。

Rails 6 版 sample_app の最終形は GitHub で公開されています。

環境

  • Ruby 2.7.6
  • Ruby on Rails 6.1.4.1
  • RBS 2.8.2
  • rbs_rails 0.11.0
  • Steep 1.3.0
  • Visual Studio Code 1.71.2

VSCode のセットアップ

実際に導入を試してみる前に、1 つだけ準備しておきたいことがあります。それはエディタのセットアップです。今回は Visual Studio Code (以下「VSCode」と表記) を使用します。本来エディタは個々人の好きなものを使っていただけば良いのですが、各エディタで Ruby の型情報をエディタと統合する方法が異なるため、そのすべてをサポートすることはできません。

そこで今回は、無料で使えて手軽にセットアップができる VSCode での設定方法を紹介することにしました。その他のエディタでも型情報を表示させることはできる[1]ようなので、VSCode 以外のエディタでチャレンジしてみたい方はぜひしてみてください(そしてその過程をぜひ公開していただけると需要があると思います!)。

Extensions のインストール

セットアップと言っても 2 つの Extension をインストールするだけです。それぞれ VSCode 上で Extensions の検索窓から検索し、インストールしてください。

Steep

https://marketplace.visualstudio.com/items?itemName=soutaro.steep-vscode

Steep と VSCode を連携するための Extension です。こちらをインストールすると、型情報のあるメソッドなどにマウスホバーしたときに型情報を表示してくれます。以下の画像のように表示されます。

RBS Syntax

https://marketplace.visualstudio.com/items?itemName=soutaro.rbs-syntax

rbs ファイルのシンタックスハイライトやインデントのサポートをしてくれる Extension です。rbs はデフォルトでサポートされていないため、この Extension を入れないと文字色がつかず見づらいです。

VSCode のセットアップは以上です。

導入手順

それではここからは実際に手を動かしながら Rails アプリケーションへの型導入を試してみましょう。

sample_app を clone

まずは型を導入する対象のアプリケーションとして Rails チュートリアル の sample_app を手元に用意します。 こちらのリポジトリ で公開されていますが、clone するのに一手間必要なので以下のリポジトリからも clone できます。

https://github.com/fugakkbn/rails_tutorial_with_signature

ターミナルで以下のコマンドを実行して clone してください。

$ git clone https://github.com/fugakkbn/rails_tutorial_with_signature.git

clone が完了したら README にあるとおりコマンドを実行して開発環境のセットアップを行います。ついでに型導入用のブランチも切っておきます。

$ bundle install --without production
$ bin/rails db:migrate
$ bin/rails test:all

# 後で使用するのでseedファイルも読み込んでおく
$ bin/rails db:seed

# 新しいブランチを作成
$ git switch -c introduce-type-signatures

ここまででセットアップは完了です。

必要な Gem のインストール

続いて型導入に必要な Gem をインストールしていきます。Gemfiledevelopment group の部分を以下のように書き換えてください。書き換えたらターミナルで bundle install を実行します。

Gemfile
.
.
group :development do
+ gem 'rbs', require: false
+ gem 'rbs_rails', require: false
+ gem 'steep', require: false
  gem 'web-console',           '4.0.1'
  gem 'listen',                '3.1.5'
  gem 'spring',                '2.1.0'
  gem 'spring-watcher-listen', '2.0.1'
end
.
.
$ bundle install

ここで追加している Gem について簡単に説明しておきます。

RBS

Ruby プログラムに型定義をするための言語です。独自の記法ではあるのですが、「Ruby っぽい記述」ができるため Ruby を書いている方なら無理なく読めるでしょう。Ruby プログラム( .rb ファイル)とは別のファイル( .rbs ファイル)として型定義をしていきます。

https://github.com/ruby/rbs

rbs_rails

Rails 向けに rbs ファイルを生成してくれる gem です。特定のコマンドを実行すると、アプリケーションのコードに対する rbs ファイルを生成してくれます。

https://github.com/pocke/rbs_rails

Steep

Ruby の静的型解析のための gem です。型解析というのは平たく言うと「型情報とアプリケーションのコードから型的におかしな部分がないかを確認する」ことです。型解析を実行するための steep check や型定義ファイルの構文チェックをする steep validate コマンドをよく使います。

https://github.com/soutaro/steep

Rails 用の型定義ファイルの生成

ここからは型定義ファイルを生成していきます。まずは先ほどインストールした rbs_rails を使用して自動生成するところからはじめます。

Rake Task の定義

rbs_rails の README を見ると、Rake タスク を定義してその Task を実行することで型定義ファイルを自動生成してくれるようです。README を参考に rbs_rails:install コマンドを実行してみます。

$ bin/rails g rbs_rails:install

すると lib/tasks/rbs.rake が生成され、以下の Rake タスクが定義されます。

  • rbs_rails:generate_rbs_for_models: Active Recordモデル用のRBSファイルを生成
  • rbs_rails:generate_rbs_for_path_helpers: パスヘルパーのRBSファイルを生成
  • rbs_rails:all: RBS Railsの全タスク(generate_rbs_for_models, generate_rbs_for_path_helpers タスクの両方)を実行する

コマンドで確認すると確かに上記のタスクが定義されているようです。

$ bin/rake -T | grep rbs
rake rbs_rails:all                            # Run all tasks of rbs_rails
rake rbs_rails:generate_rbs_for_models        # Generate RBS files for Active Record models
rake rbs_rails:generate_rbs_for_path_helpers  # Generate RBS files for path helpers

Rake Task を実行して型定義ファイルを生成

今回はモデルとパスヘルパー両方の型定義を生成したいので、rbs_rails:all タスクを実行するのがよさそうです。さっそく実行してみましょう。

$ bin/rake rbs_rails:all

実行すると以下の構成で型定義ファイルが自動生成されます。

$ tree sig 
sig/
└── rbs_rails/
    ├── app/
    │   └── models/
    │       ├── active_storage/
    │       │   ├── attachment.rbs
    │       │   ├── blob.rbs
    │       │   └── variant_record.rbs
    │       ├── micropost.rbs
    │       ├── relationship.rbs
    │       └── user.rbs
    ├── model_dependencies.rbs
    └── path_helpers.rbs

アプリケーション root に sig/ ディレクトリが生成されています。基本的に rbs ファイルは sig/ 配下に配置していくことになります[2]。その下に rbs_rails/ ディレクトリがあり、この中に今回 rbs_rails によって自動生成された rbs ファイルが配置されています。
models/ ディレクトリ配下を見ると micropost, relationship, user といった sample_app で使用したモデルの型定義が生成されていることがわかります。

ただしここでは Rails が自動的に定義するメソッドのみが型定義され、自身で def を使って定義したメソッドの型定義は追加されません。それらは後で別で追加する必要があります。

path_helpers.rbs はその名の通りパスヘルパーメソッドに対する型定義ファイルです。中身を見てみましょう(長いので折りたたんでいます)。

sig/rbs_rails/path_helpers.rbs
sig/rbs_rails/path_helpers.rbs
interface _RbsRailsPathHelpers
  def rails_mailers_path: (*untyped) -> String
  def rails_info_properties_path: (*untyped) -> String
  def rails_info_routes_path: (*untyped) -> String
  def rails_info_path: (*untyped) -> String
  def root_path: (*untyped) -> String
  def help_path: (*untyped) -> String
  def about_path: (*untyped) -> String
  def contact_path: (*untyped) -> String
  def signup_path: (*untyped) -> String
  def login_path: (*untyped) -> String
  def logout_path: (*untyped) -> String
  def following_user_path: (*untyped) -> String
  def followers_user_path: (*untyped) -> String
  def users_path: (*untyped) -> String
  def new_user_path: (*untyped) -> String
  def edit_user_path: (*untyped) -> String
  def user_path: (*untyped) -> String
  def edit_account_activation_path: (*untyped) -> String
  def password_resets_path: (*untyped) -> String
  def new_password_reset_path: (*untyped) -> String
  def edit_password_reset_path: (*untyped) -> String
  def password_reset_path: (*untyped) -> String
  def microposts_path: (*untyped) -> String
  def micropost_path: (*untyped) -> String
  def relationships_path: (*untyped) -> String
  def relationship_path: (*untyped) -> String
  def turbo_recede_historical_location_path: (*untyped) -> String
  def turbo_resume_historical_location_path: (*untyped) -> String
  def turbo_refresh_historical_location_path: (*untyped) -> String
  def rails_postmark_inbound_emails_path: (*untyped) -> String
  def rails_relay_inbound_emails_path: (*untyped) -> String
  def rails_sendgrid_inbound_emails_path: (*untyped) -> String
  def rails_mandrill_inbound_health_check_path: (*untyped) -> String
  def rails_mandrill_inbound_emails_path: (*untyped) -> String
  def rails_mailgun_inbound_emails_path: (*untyped) -> String
  def rails_conductor_inbound_emails_path: (*untyped) -> String
  def new_rails_conductor_inbound_email_path: (*untyped) -> String
  def edit_rails_conductor_inbound_email_path: (*untyped) -> String
  def rails_conductor_inbound_email_path: (*untyped) -> String
  def new_rails_conductor_inbound_email_source_path: (*untyped) -> String
  def rails_conductor_inbound_email_sources_path: (*untyped) -> String
  def rails_conductor_inbound_email_reroute_path: (*untyped) -> String
  def rails_conductor_inbound_email_incinerate_path: (*untyped) -> String
  def rails_service_blob_path: (*untyped) -> String
  def rails_service_blob_proxy_path: (*untyped) -> String
  def rails_blob_representation_path: (*untyped) -> String
  def rails_blob_representation_proxy_path: (*untyped) -> String
  def rails_disk_service_path: (*untyped) -> String
  def update_rails_disk_service_path: (*untyped) -> String
  def rails_direct_uploads_path: (*untyped) -> String
  def rails_representation_path: (*untyped) -> String
  def rails_blob_path: (*untyped) -> String
  def rails_storage_proxy_path: (*untyped) -> String
  def rails_storage_redirect_path: (*untyped) -> String
  def rails_mailers_url: (*untyped) -> String
  def rails_info_properties_url: (*untyped) -> String
  def rails_info_routes_url: (*untyped) -> String
  def rails_info_url: (*untyped) -> String
  def root_url: (*untyped) -> String
  def help_url: (*untyped) -> String
  def about_url: (*untyped) -> String
  def contact_url: (*untyped) -> String
  def signup_url: (*untyped) -> String
  def login_url: (*untyped) -> String
  def logout_url: (*untyped) -> String
  def following_user_url: (*untyped) -> String
  def followers_user_url: (*untyped) -> String
  def users_url: (*untyped) -> String
  def new_user_url: (*untyped) -> String
  def edit_user_url: (*untyped) -> String
  def user_url: (*untyped) -> String
  def edit_account_activation_url: (*untyped) -> String
  def password_resets_url: (*untyped) -> String
  def new_password_reset_url: (*untyped) -> String
  def edit_password_reset_url: (*untyped) -> String
  def password_reset_url: (*untyped) -> String
  def microposts_url: (*untyped) -> String
  def micropost_url: (*untyped) -> String
  def relationships_url: (*untyped) -> String
  def relationship_url: (*untyped) -> String
  def turbo_recede_historical_location_url: (*untyped) -> String
  def turbo_resume_historical_location_url: (*untyped) -> String
  def turbo_refresh_historical_location_url: (*untyped) -> String
  def rails_postmark_inbound_emails_url: (*untyped) -> String
  def rails_relay_inbound_emails_url: (*untyped) -> String
  def rails_sendgrid_inbound_emails_url: (*untyped) -> String
  def rails_mandrill_inbound_health_check_url: (*untyped) -> String
  def rails_mandrill_inbound_emails_url: (*untyped) -> String
  def rails_mailgun_inbound_emails_url: (*untyped) -> String
  def rails_conductor_inbound_emails_url: (*untyped) -> String
  def new_rails_conductor_inbound_email_url: (*untyped) -> String
  def edit_rails_conductor_inbound_email_url: (*untyped) -> String
  def rails_conductor_inbound_email_url: (*untyped) -> String
  def new_rails_conductor_inbound_email_source_url: (*untyped) -> String
  def rails_conductor_inbound_email_sources_url: (*untyped) -> String
  def rails_conductor_inbound_email_reroute_url: (*untyped) -> String
  def rails_conductor_inbound_email_incinerate_url: (*untyped) -> String
  def rails_service_blob_url: (*untyped) -> String
  def rails_service_blob_proxy_url: (*untyped) -> String
  def rails_blob_representation_url: (*untyped) -> String
  def rails_blob_representation_proxy_url: (*untyped) -> String
  def rails_disk_service_url: (*untyped) -> String
  def update_rails_disk_service_url: (*untyped) -> String
  def rails_direct_uploads_url: (*untyped) -> String
  def rails_representation_url: (*untyped) -> String
  def rails_blob_url: (*untyped) -> String
  def rails_storage_proxy_url: (*untyped) -> String
  def rails_storage_redirect_url: (*untyped) -> String
end

今までに何度も使った users_pathmicroposts_url のようなパスヘルパー用の型定義が生成されていることがわかります。

gem_rbs_collection で gem の型定義をダウンロードする

続いて Gem の型定義ファイルをダウンロードします。現在 Gem の型定義については gem_rbs_collection というリポジトリに集約されており[3]、そちらからダウンロードすることで使うことができます。

https://github.com/ruby/gem_rbs_collection

ダウンロードするための設定からしていきます。

$ bundle exec rbs collection init

コマンドを実行すると rbs_collection.yaml が生成されます。これは gem_rbs_collection 用の設定ファイルです。以下の内容で生成されますが、ひとまず内容を理解する必要はありません。

rbs_collection.yaml
# Download sources
sources:
  - name: ruby/gem_rbs_collection
    remote: https://github.com/ruby/gem_rbs_collection.git
    revision: main
    repo_dir: gems

# A directory to install the downloaded RBSs
path: .gem_rbs_collection

gems:
  # Skip loading rbs gem's RBS.
  # It's unnecessary if you don't use rbs as a library.
  - name: rbs
    ignore: true

実際にダウンロードするためのコマンドを実行します。

$ bundle exec rbs collection install

実行すると.gem_rbs_collection/ ディレクトリに、現在 gem_rbs_collection リポジトリにある gem の型定義ファイルがダウンロードされます。通常このディレクトリはプロジェクトのリポジトリで管理する必要がないため、.gitignore ファイルに追記しておくと良いでしょう。

.gitignore
.
.
.gem_rbs_collection/

Steep のセットアップ

続いて型解析を実行するために Steep のセットアップをしていきます。

$ bundle exec steep init

これを実行すると Steep 用の設定ファイルである Steepfile が生成されるます。生成された Steepfile を以下の内容に置き換えてください。それぞれの行の意味はコメントを参照してください。

Steepfile
target :app do
  signature 'sig'  # 型定義ファイルの配置ディレクトリを指定
  check 'app'      # 解析対象のディレクトリを指定
  
  library 'rbs'    # 型定義を使用したい標準添付ライブラリを指定
end

これで型解析を実行する準備が整いました。さっそく型解析を実行してみましょう。

$ bundle exec steep check

実行すると、以下のようなエラーや警告が出たのではないでしょうか。

# Type checking files:

........................................................................................................................................................................................F...F................F...F.......F.F.................F...........FF.F....F.F......FF......F.F.......F.F............FFFF....FF..F......FFF..F.F.F...........F..F.FF.F..FFFF.....F.FF..........FFFF.....F.......F..F....F..........FF..

app/controllers/relationships_controller.rb:1:6: [warning] Cannot find the declaration of class: `RelationshipsController`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ class RelationshipsController < ApplicationController
        ~~~~~~~~~~~~~~~~~~~~~~~

app/controllers/relationships_controller.rb:1:32: [warning] Cannot find the declaration of class: `ApplicationController`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ class RelationshipsController < ApplicationController
                                  ~~~~~~~~~~~~~~~~~~~~~
.
.

はい、これでひとまず「エラーや警告は出るが型解析を実行できる状態」になりました。ここまでで第一段階は完了です。これ以降は警告されている型定義の不足などを補うため、手動で型定義を足していく作業になります。

モデルのメソッドの型定義を足していく

ここからは自動生成されていないメソッドなどに対して型定義をしていきます。
簡単のため、steep check の解析範囲を狭めます。

Steepfile
target :app do
  signature 'sig'
  check 'app/models/user.rb'  # 解析対象をUserモデルのみに絞っている
  
  library 'rbs'
end

この状態で改めて steep check を実行してみると、以下のエラーや警告が出るはずです(他にも出ていると思います)。

app/models/user.rb:16:2: [warning] Cannot find the declaration of constant: `VALID_EMAIL_REGEX`
│ Diagnostic ID: Ruby::UnknownConstant
│
└   VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    ~~~~~~~~~~~~~~~~~
    
app/models/user.rb:114:36: [error] Type `singleton(::User)` does not have method `new_token`
│ Diagnostic ID: Ruby::NoMethod
│
└       self.activation_token  = User.new_token
                                      ~~~~~~~~~

1 つ目の warning は VALID_EMAIL_REGEX 定数の型定義が見つからない、という内容の警告です。2 つ目の error は User モデルに new_token メソッドはないよ、という内容です。User モデルに new_token メソッドは定義されているのに、おかしいですね。

これらを解決するための型定義を書いてみましょう。sig/app/models/user.rbs ファイルを追加して、中身を以下のように書きます。

sig/app/models/user.rbs
class User < ApplicationRecord
  VALID_EMAIL_REGEX: Regexp

  def self.new_token: () -> String
end

この状態で再度 steep check を実行してみましょう。先ほどの警告とエラーが出なくなったことが確認できると思います。

また、VSCode で app/models/user.rb を開き new_token メソッドにマウスホバーしてみましょう。すると以下の画像のように、先ほど定義した型情報が表示されるはずです。

この後も多くのメソッド等に型定義をしていくのでぜひマウスホバーして確かめてみてください。また、自分で定義していないメソッドの型情報がどのように表示されるかなども見てみると、おもしろいですよ。

このように、基本的にはエラーや警告を見て型定義の不足を確認した上で追加していく、といった流れで型定義を進めていきます。進め方の雰囲気を掴んでもらえたら、引き続き型定義を進めながら RBS の文法についても見ていきましょう。

RBS の文法

型定義を進めていくために、User モデルの各メソッドに型定義をしながら RBS の文法について説明します。すべての構文を紹介することはできませんが、基本的な部分はこれで抑えられるかと思います。

型定義の対象

型定義をするべき対象としては以下がメインになります。

  • クラスメソッド(singleton メソッド)
  • インスタンスメソッド
  • 定数
  • インスタンス変数

可視性が private や protected のメソッドにも型定義ができます。

基本的な構文

先ほど例に出した型定義をもとに説明します。

sig/app/models/user.rbs
class User < ApplicationRecord
  VALID_EMAIL_REGEX: Regexp

  def self.new_token: () -> String
end

まず class User ... end の構文でどのクラスの型定義なのかを記述します。これは Ruby の構文と同じです。< ApplicationRecord となっているように継承も表現できますし、同様にして Module の型定義を記述することもできます。

続いて def self.new_token: () -> String の部分がメソッドの型定義の部分です。def キーワードに続いて型定義をしたいメソッド名、: と続き、引数がある場合は引数名とその型を ( ) の中に書きます(このメソッドには引数がないため () の中は空ですが)。そして -> の後に戻り値の型を書きます。

def <メソッド名>: (<引数の型>) -> <戻り値の型>

メソッド名の前の self は Ruby と同じくクラスメソッドを表す書き方です。
つまり def self.new_token: () -> String は「new_token (クラス)メソッドは String 型の戻り値を返すメソッド」だと定義していることになります。

また、少し戻って VALID_EMAIL_REGEX: Regexp の部分は定数の型定義を記述している部分になります。定数名: 型 の形になり、インスタンス変数も同様の形式で型定義することができます。

<定数名>: <定数の型>
<インスタンス変数名>: <インスタンス変数の型>

これが基本の書き方になります。これをもとにして User モデルの他のメソッドについても型定義を進めてみましょう。

引数を受け取るメソッド

さきほど引数の型を書く箇所を示しましたが、User モデルのメソッドで実例を見てみましょう。

User モデルに以下のメソッドがあります。

def authenticated?(attribute, token)
  digest = send("#{attribute}_digest")
  return false if digest.nil?
  BCrypt::Password.new(digest).is_password?(token)
end

このメソッドは attribute, token の 2 つの引数を受け取るので、型定義をするためには引数の型を知る必要があります。この引数の型を確認するためにテストでの使われ方を見てみましょう。

test/models/user_test.rb
test "authenticated? should return false for a user with nil digest" do
  assert_not @user.authenticated?(:remember, '')
end

引数を見ると第一引数は Symbol 型、第二引数は String 型を渡しているようです。またメソッドの最終行を見ると

BCrypt::Password.new(digest).is_password?(token)

となっており、トークンとパスワードダイジェストが一致するかどうかを真偽値で返すメソッドが呼ばれています。つまりこのメソッドの返り値は真偽値になります。

これらを勘案すると authenticated? メソッドの型定義は次のようになります。

def authenticated?: (Symbol, String) -> bool

このように引数の型は引数の順番に書きます。ちなみに以下のように引数名と型を書いた場合は、キーワード引数の型定義になります。

def authenticated?: (attribute: Symbol, token: String) -> bool

そして真偽値の型を表すのは bool です。特に true, false を表す場合に使います。ちなみに真偽値ではないが真偽判定に使う値を表す場合に使用する boolish という型もあります。

戻り値を扱わないメソッド

メソッドの中には戻り値を値として使用する訳ではないものも多くあります。例えば User モデルの forget メソッドがこれにあたります。

def forget
  update_attribute(:remember_digest, nil)
end

このメソッドはユーザーがログアウト操作をした際に認証に使用しているダイジェストを破棄するメソッドですが、このようにモデルの属性を更新する処理は戻り値を期待しているわけではありません。このような場合の型定義には void を使用します。

これを踏まえると forget メソッドの型定義は次のようになります。

def forget: () -> void

また、モデルの新規追加、削除、メール送信なども同様の考え方で void を使います[4]。たとえば send_activation_email, follow, unfollow メソッドなどがこれにあたります。

def send_activation_email: () -> void
def follow: (User) -> void
def unfollow: (User) -> void

follow, unfollow メソッドでは User 型の引数を受け取っていることに注意してください。

戻り値が ActiveRecord オブジェクトの場合

feed メソッドは User が follow している User の Micropost を取得するメソッドですが、このメソッドの型はどのように書くのが良さそうでしょうか?このような時は、実際にメソッドを実行してみて確認すると良いでしょう。

$ bin/rails c
> user = User.first
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2022-12-31 09:51:17.052270000 +0000", updated_at: "2022-12-31 09:51:17.052270000 +0000", password_digest: [FILTERED], remember_digest: nil...

> user.feed
=> #<ActiveRecord::Relation [#<Micropost id: 300, content: "Perferendis maiores quod repellendus sed.", user_id: 6, created_at: "2022-12-31 09:52:18.766229000 +0000", updated_at: "2022-12-31 09:52:18.766229000 +0000">, #<Micropost id: 299,...

> user.feed.class
=> Micropost::ActiveRecord_Relation

最後の user.feed.classuser.feed の戻り値のクラスを確認した結果、このメソッドの戻り値は Micropost::ActiveRecord_Relation 型であることがわかります。よって feed メソッドの型定義は次のようになります。

sig/app/models/user.rbs
def feed: () -> Micropost::ActiveRecord_Relation

ただこの型は、Micropost 型が含まれていることからわかる通り sample_app 特有の型です。この型はいったいどこから出てきたのでしょうか?

実はこの型は rbs_rails で自動生成した型定義ファイルの中で定義されています。具体的には sig/rbs_rails/app/models/micropost.rbs の最後の方で以下のように定義されています。

sig/rbs_rails/app/models/micropost.rbs
class Micropost < ::ApplicationRecord
.
.
  class ActiveRecord_Relation < ::ActiveRecord::Relation
    include GeneratedRelationMethods
    include _ActiveRecord_Relation[Micropost, Integer]
    include Enumerable[Micropost]
  end
.
.
end
アクセサメソッド

attr_accessor などのアクセサメソッドに対しても型定義ができます。User モデルでも以下のようにアクセサメソッドが使われています。

app/models/user.rb
attr_accessor :remember_token, :activation_token, :reset_token

アクセサメソッドの型定義は次の形で記述します。

attr_<accessor|reader|writer> <アクセサメソッド名> (<引数の型>): -> <戻り値の型>

よって、上記のアクセサメソッドの型定義は以下のようになります。

app/models/user.rb
attr_accessor remember_token (): String
attr_accessor activation_token (): String
attr_accessor reset_token (): String

型定義の例

ここまでに紹介した記法で User モデルの型をすべて定義することができます。

sig/app/models/user.rbs
class User < ApplicationRecord
  attr_accessor remember_token (): String
  attr_accessor activation_token (): String
  attr_accessor reset_token (): String

  VALID_EMAIL_REGEX: Regexp

  def self.digest: (String) -> BCrypt::Password
  def self.new_token: () -> String

  def remember: () -> void
  def authenticated?: (Symbol, String) -> bool
  def forget: () -> void
  def activate: () -> void
  def send_activation_email: () -> void
  def create_reset_digest: () -> void
  def send_password_reset_email: () -> void
  def password_reset_expired?: () -> bool
  def feed: () -> Micropost::ActiveRecord_Relation
  def follow: (User) -> void
  def unfollow: (User) -> void
  def following?: (User) -> bool

  private
  def downcase_email: () -> void
  def create_activation_digest: () -> void
end

より詳しく RBS の文法を知りたい方は rbs/syntax.md at master · ruby/rbs を参照してください。rbs/rbs_by_example.md at master · ruby/rbs にはサンプルコードもあります。

型定義をしたことによるメリットの確認

User モデルの型定義をすることができましたが、型があると具体的にどんなことがうれしいのでしょうか?それを体感していただくために、以下の内容を試してみてください。

入力補完の精度を確認する

今型定義をした authenticated? メソッドの第二引数の型を untyped にしてみましょう。untyped は「型情報が不明」な状態を表す型[5]です。

def authenticated?: (Symbol, untyped) -> bool

この状態で引数 token に対してメソッドを呼び出すため . を入力すると、メソッドの補完候補がエディタ上で表示されます。token は String 型なので String クラスのメソッドが表示されるとうれしいですが、この状態だと表示されるのは Object クラスのメソッドばかりです。

では再度、引数 token の型を String にしてみます。

def authenticated?: (Symbol, String) -> bool

そして先ほどと同じように token に対してメソッドを呼び出そうとすると、今度は補完候補に String クラスのメソッドが表示されるようになりました。

これが正に型情報による恩恵です。ただし、ここで示した例は RubyMine での例です(すみません)。VSCode だとこのようにわかりやすい結果が出せないため RubyMine の例をお見せしました。ただ、VSCode でも同様に型情報があることによって補完候補が絞られているようなので、上記と同じことをしたらどのような挙動をするかぜひ試してみてください。

型の不一致を検知する

再度 authenticated? メソッドを例にします。このメソッドは Symbol 型の第一引数と String 型の第二引数を取ります。第二引数にあえて Integer 型の引数を与えるとどうなるでしょうか。

app/controllers/account_activations_controller.rb の 5 行目で authenticated? メソッドを呼び出しているので書き換えてみます。

app/controllers/account_activations_controller.rb
- if user && !user.activated? && user.authenticated?(:activation, params[:id])
+ if user && !user.activated? && user.authenticated?(:activation, 123)

Steepfile の解析対象を以下のように変更した後、steep check を実行してみましょう。

Steepfile
.
.
check 'app/controllers/account_activations_controller.rb'
.
.
$ bundle exec steep check
# Type checking files:

.....................................
app/controllers/account_activations_controller.rb:5:68: [error] Cannot pass a value of type `::Integer` as an argument of type `::String`
│   ::Integer <: ::String
│     ::Numeric <: ::String
│       ::Object <: ::String
│         ::BasicObject <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└     if user && !user.activated? && user.authenticated?(:activation, 123)
                                                                      ~~~

上記のようなエラーが出力されているのが確認できるはずです。エラーの内容を読んでみると「型 ::Integer の値を型 ::String の引数として渡すことはできません。」と出力されており、期待通り型の不一致を捕捉してくれました。

このように型定義にそぐわない呼び出し方をした場合に検知できると、バグを埋め込んだ状態でリリースすることを防げる可能性が高くなります[6]

型の自動生成について

ここまで User モデルの型定義は手書きしてきましたが、ツールを使って自動生成することもできます。RBS にはコマンドとして rbs prototype があり、こちらが型情報を自動生成するためのコマンドになっています。また、同じく Ruby 本体にバンドルされている TypeProf というツールもあります。

https://github.com/ruby/typeprof

ここでは rbs prototype rb コマンドでの自動生成についてご紹介します。紹介程度で詳しい解説はしませんので、もし詳しく知りたい方は以下の記事がとてもわかりやすいので参考にしてください(RBS を開発されている方の記事です)。

https://pocke.hatenablog.com/entry/2020/12/18/230235

RBS での自動生成

それでは User モデルの型定義ファイルを自動生成してみましょう。自動生成は以下のコマンドを実行するだけです。

$ bundle exec rbs prototype rb app/models/user.rb >> sig/app/models/user-generated.rbs

このコマンドを実行すると、sig/app/models/user-generated.rbs ファイルが以下の内容で出力されているはずです。

sig/app/models/user-generated.rbs
class User < ApplicationRecord
  attr_accessor remember_token: untyped

  attr_accessor activation_token: untyped

  attr_accessor reset_token: untyped

  VALID_EMAIL_REGEX: ::Regexp

  # 渡された文字列のハッシュ値を返す
  def self.digest: (untyped string) -> untyped

  # ランダムなトークンを返す
  def self.new_token: () -> untyped

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember: () -> untyped

  # トークンがダイジェストと一致したらtrueを返す
  def authenticated?: (untyped attribute, untyped token) -> (false | untyped)

  # ユーザーのログイン情報を破棄する
  def forget: () -> untyped

  # アカウントを有効にする
  def activate: () -> untyped

  # 有効化用のメールを送信する
  def send_activation_email: () -> untyped

  # パスワード再設定の属性を設定する
  def create_reset_digest: () -> untyped

  # パスワード再設定のメールを送信する
  def send_password_reset_email: () -> untyped

  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?: () -> untyped

  # ユーザーのステータスフィードを返す
  def feed: () -> untyped

  # ユーザーをフォローする
  def follow: (untyped other_user) -> untyped

  # ユーザーをフォロー解除する
  def unfollow: (untyped other_user) -> untyped

  # 現在のユーザーがフォローしてたらtrueを返す
  def following?: (untyped other_user) -> untyped

  private

  # メールアドレスをすべて小文字にする
  def downcase_email: () -> untyped

  # 有効化トークンとダイジェストを作成および代入する
  def create_activation_digest: () -> untyped
end

先ほど手書きで型定義した user.rbs と似たような内容が出力されていますね。少し違うところとして

def follow: (untyped other_user) -> untyped

# 手書きしたときは以下のように書いた
def follow: (User) -> void

このように

def <メソッド名>: (<引数の型> <引数名>) -> <戻り値の型>

と書くこともできます。

しかし、よく見てみると引数や戻り値の型がほとんど untyped になっています。rbs prototype rb コマンドは Ruby コードを静的に解析して型情報を出力しているため、ほとんどが untyped として出力されます(単純なケースでは String などの型情報が生成される場合もあります)。そのため自動生成した後に改めて型情報を修正したり、誤りがないかを確認する必要があるのが現状です。

今回の User モデルのように小さなコードであれば型定義をするのもそこまで大変な作業ではありませんが、実際に世の中で動いているアプリケーションはもっと大きな規模のコードベースです。それを手作業で型定義するのは容易ではありません。そういった背景もあり、最近はより正確な型情報を自動生成するツールを開発されている方もいらっしゃいます。プログラマーが手作業で型定義しなくても良い未来がくるかもしれません。

おわりに

以上で sample_app への型導入の解説は終わりです。今回は User モデルのみに型定義をしてみましたが、Micropost モデルや Relationship モデルへの型定義にもぜひチャレンジしてみてください。

また、Ruby の型周りはまだエコシステムが成熟しているわけではなく、これからも新しい機能や技術が出てくるものと思われます(先ほどご紹介した型情報の自動生成などもその一つです)。興味のある方はその辺りの動向をチェックしておくとおもしろいかもしれません。

この記事へのフィードバックがありましたら Twitter から DM をいただければと思います。最後までお読みいただきありがとうございました

脚注
  1. 例えば僕は普段 RubyMine という IDE を使っていて、RubyMine ではデフォルトで型情報の表示やシンタックスハイライトがサポートされています。 ↩︎

  2. 必ずしも sig でなくても問題ありません。Steepfile の signature に指定すればどのディレクトリでも動作します。 ↩︎

  3. Gem 自体に型定義を含めることもできますし、将来的にはそちらが主流になりそうです。が、型を書くかどうかはメンテナ次第なので第三者が型を書く場合にはひとまず gem_rbs_collection に置く、というのが現状です ↩︎

  4. ただしモデルの更新や削除の結果によって処理を分岐したいケースもあるでしょう。その場合は boolish 型が正しいと言えそうですが、実際どちらにすべきかは僕もよくわかっていません ↩︎

  5. TypeScript でいうところの any↩︎

  6. そのために CI で steep check を実行する必要がありますが、なかなか難しいのが現状です。 ↩︎

Discussion

ログインするとコメントできます