Rubyの型チェッカー「Sorbet」を導入した話

リンクアンドモチベーション [Advent Calendar 2023](https://qiita.com/advent-calendar/2023/lmi)の15日目の記事です:christmas_tree:

# はじめに
株式会社リンクアンドモチベーションの葛葉（くずば）です。
現在新卒2年目で、[モチベーションクラウド](https://www.motivation-cloud.com/)のバックエンドエンジニアとして、新規機能の開発を担当しています。
複数の言語でもサーベイの回答、結果の確認ができるようにする開発をおこなっています。

本記事では、メイン業務と並行で進めていたプロジェクト **「Ruby（Ruby on Rails）における静的型付け」** について書きたいと思います。

同じように静的型付けを導入しようとしているプロダクトは少なくないと思います。
この記事を見ることで、

:::note info
- 静的型付けを導入するメリットが分かる
- 代表的なツールであるSorbetの使い方が分かる
:::

記事となっています。少しでも参考になれば幸いです。

# 【背景】 なぜ型宣言する必要があるのか
## 1.動的型付け言語「Ruby」の特徴
プロダクトに使われている言語「Ruby」は動的型付け言語と言われていて、型宣言しなくても良い（整数型、文字列型などを定義する時に明示しなくて良い）言語になります。
PythonやJavaScriptも同様です。
動的型付け言語と静的型付け言語の違いを考えてみます。
<br>

**動的型付け言語のRubyで書いた場合**
```ruby:Ruby
def sum_number(x, y)
  x + y
end
```
メソッドを定義する時、型宣言しなくても良いのでシンプルです。

<br>

**静的型付け言語のGoで書いた場合**
```go:Go
func sumNumber(x int, y int) int {
  return x + y
}
```
静的型付け言語だと、
intの引数が入ること、返り値はintであることを明記する必要があります。

<br>

## 2.動的型付け言語のメリットとデメリット
動的型付け言語は型を意識せずにどんどん開発を進めていけるので、
柔軟性のあるコードが書けたり、簡潔に書けたりするなど、スピード感のある開発ができます。

一方で、型宣言してないことで、
- 意図しない型が入りバグが起こること
- バグに気づくのが遅くなること
することもあります。

弊社プロダクトにおいても、**想定していない型が入りインシデントになる**こともありました。


## 3.型検査があれば防げたかもしれないインシデント
例えば、以下のようなメソッドによってインシデントが起きた時がありました。
```ruby:Ruby
def add_label(value)
  return "Ruby" if value == 1

  ""
end
```
valueを使って、分岐するメソッドです。
もし、「valueが1なら"Ruby"を返す」という内容になります。

メソッドを作成した開発者は、valueという引数に`1`か`0`が入ることを想定していたはずです。

しかし、実際のコードでは、add_labelメソッドの引数に`true`と`false`が入る実装になっていました。

結果、全ての値が空文字列 "" となりインシデントになってしまいました。

こういった**インシデントを防ぐために、型検査を導入すると良いのでは？** となりました。

# 【導入】 型検査のライブラリの検討

型検査を行うライブラリは複数ありますが、最終的には以下のような理由からSorbetを導入することになりました。

:::note info
- 検査のスピードが速い
- 大企業が開発していて将来性も十分と判断できる
- Rubyの文法で書けるので学習コストが低い
:::

# 【理解】 Sorbetによる型検査
## 1.検査方法は2つ
型を検査する場合、静的型検査（Static Type Checking）と動的型検査（Dynamic Type Checking）の2つがあります。

- 静的型検査 → コードを実行せずに検査する
- 動的型検査 → コードを実行することで検査する

Sorbetではどちらの検査方法も提供されています。

### 静的型検査
まずは静的型検査です。
静的型検査は、コードを実行せずに定義された型をもとに検査します。

日付フォーマットを分岐するコードを模倣してメソッドを作成しました。
これを使って検査してみます。
```app/models/lang.rb
  sig { params(lang: String).returns(String) }
  def self.date_format(lang)
    case lang
    when "Indonesian", "Spanish", "French"
      "%d/%m/%Y %H:%M"
    else
      "%Y/%m/%d %H:%M"
    end
  end
```

以下のようにget_constant_numberメソッドを加えます。
また、最後の行ではdate_formatメソッドの引数に誤った型が入るように追記しました。
```app/models/lang.rb
  # コード例1
  sig { returns(Integer) }
  def self.get_constant_number
    100
  end

  sig { params(lang: String).returns(String) }
  def self.date_format(lang)
    case lang
    when "Indonesian", "Spanish", "French"
      "%d/%m/%Y %H:%M"
    else
      "%Y/%m/%d %H:%M"
    end
  end

  Lang.date_format(Lang.get_constant_number)
```
<br>

では、ターミナルで以下を実行し、Sorbetの静的型検査を行ってみます。
```console
$ srb tc .
```
※ tcは"type checking"（型検査）の意味


すると、以下のような指摘が返ってきました。
```zsh
$ srb tc .
app/models/language.rb:257: Expected String but found Integer for argument lang https://srb.help/7002
     257 |  Lang.date_format(Lang.get_constant_number)
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  Expected String for argument lang of method Language.date_format:
    app/models/lang.rb:247:
     247 |  sig { params(lang: String).returns(String) }
                         ^^^^
  Got Integer originating from:
    app/models/lang.rb:257:
     257 |  Lang.date_format(Lang.get_constant_number)
                             ^^^^^^^^^^^^^^^^^^^^^^^^^
Errors: 1
```
「Stringを期待したいたのに、Integerが入ってきた」という内容になります。

ポイントは、この時**コード自体が実行されたわけではない**ということです。

Sorbetは、メソッドの引数と戻り値の型（sigで定義したもの）を元に、型の整合性を検査してくれているので、コードを実行しなくても間違った使い方のコードを指摘してくれています。

では、もし、get_constant_numberメソッドにsigをつけ忘れてしまった場合はどうでしょうか？

get_constant_numberメソッドのsigをコメントアウトして静的型検査してみます。

```app/models/lang.rb
  # コード例2
  # sig { returns(Integer) }  ⇦ 型を書き忘れてしまった!
  def self.get_constant_number
    100
  end

  sig { params(lang: String).returns(String) }
  def self.date_format(lang)
    case lang
    when "Indonesian", "Spanish", "French"
      "%d/%m/%Y %H:%M"
    else
      "%Y/%m/%d %H:%M"
    end
  end

  Lang.date_format(Lang.get_constant_number)
```


```console
$ srb tc .
No errors! Great job.
```
「エラーはない！グッジョブです（￣+ー￣）」 ⇦ いや、グッジョブじゃないです。

このコードには問題があるので、静的型検査で指摘してくれることを期待していたのですが、型検査されないことが分かりました。

このような状況は、以下のような理由で避けられないことがあります。

:::note warn
- 導入段階でメソッド全てに型宣言できない場合
- ライブラリのメソッドに型が定義されていない場合
:::

次に紹介する動的型検査を併用することで、こういったリスクを下げることができます。

### 動的型検査
動的型検査は、プログラム実行時に変数や式の型を検査することをいいます。
弊社では**RSpecを実行する時**に検査するように設定しています。

先ほどのコード例2のコードについてRSpecを実行してみます。

```zsh
$ rspec spec/models/lang_spec.rb
```
```zsh
$ rspec spec/models/lang_spec.rb
/Users/kuzuba/motivation-cloud/vendor/bundle/.app/ruby/3.1.0/gems/sorbet-runtime-0.5.11108/lib/types/configuration.rb:296:in `call_validation_error_handler_default': 
Parameter 'lang': Expected type String, got type Integer with value 100
Caller: /Users/kuzuba/motivation-cloud/app/models/lang.rb:257
```
実行中にエラーを起こしてくれました。

このように、静的型検査の欠点を動的型検査することでカバーすることができました。

動的型検査では、メソッドが実行されていない場合には注意してください。

## 2.検査方法まとめ
Sorbetで2種類の検査方法を行う場合のメリット、デメリットをまとめると以下のようになります。

| 種類 | メリット | デメリット　 |
| --- | --- | --- |
| 静的型検査　　　 | コードを実行しなくてもエラーを検出できる | 静的に型が決まっていないと検査が不十分になる |
| 動的型検査　　　 | 静的に型が決まっていなくてもエラーを検出できる場合がある | 実行されないコードの場合、エラーを検出できない |

このことから、静的型検査と動的型検査はどちらも使うが良いと判断し、どちらも使えるようにしました。

# 【方法】 型宣言を書く手順
## 1.型宣言を書くための3ステップ
私が行なっている方法を3ステップで紹介します。

:::note info
- Step1: sigを付ける
- Step2: 静的型検査が通るか確認する
- Step3: 動的型検査が通るか確認する
:::

各ステップごとに紹介していきます。

### Step1: sigを付ける
**1_1. sigを自動で付ける**
まずは、Sorbetのautocorrectで、sigを自動で付けます。

以下のコマンドを実行します。

```console
$ srb tc -a --typed=strict --isolate-error-code=7017 --no-config ${filepath}
```
-aオプションでautocorrectされます。
参考: https://sorbet.org/docs/sig-suggestion#suggesting-signatures-in-bulk

**1_2. T.untypedが付いている箇所は、正しい型に直す**
Sorbetのautocorrectすると、T.untyped（型が未指定）が出てしまう場合は、
手動で修正します。

**1_3. sig が付かなかったメソッドにも手でsigを書く**
sig自体がつかないメソッドもあります。
こちらも手動で型を付けていきます。

### Step2: 静的型検査が通るか確認する
以下のコマンドを使って、静的型検査を行います。

```console
$ srb tc .
```
もしエラーを吐く場合は、エラーログに従ってコードを修正します。

### Step3: 動的型検査が通るか確認する
以下のコマンドを使って、動的型検査を行います。
```console
$ rspec ${specpath}
```
実行時の型とsigで宣言した型が合わない場合はエラーになります。
もしエラーを吐く場合は、エラーログに従って修正します。


## 2.型宣言する際のルール
型宣言する際は、ファイルの1行目に型検査の厳しさ、クラスの下に`extend T::Sig`を書きます。
そして、メソッド定義の前に型を書いたら型検査されるようになります。
```ruby:Ruby
# typed: true
class Lang
  extend T::Sig
  
  sig { params(lang: String).returns(T::Boolean) }
  def self.acceptable?(lang)
    ACCEPT_LANG.include?(lang.underscore.to_sym)
  end
end
```
sigで型宣言する場合はこのように書きます。
```ruby:Ruby
sig { params(引数の型).returns(返り値の型) }
```

具体的に型の例をいくつかあげておきます。
- `String` : 文字列型
- `Interger` : 文字列型
- `T::Boolean` : 真偽値（true または false）の型
- `T.nilable` : 任意の型を受け取り、その型または nil のいずれかを許容する
  - T.nilable(String) は String 型または nil を許容する。
- `T::Array` : 配列型を示し、必要に応じて要素の型を指定できる
  - 例：T::Array[Integer] は整数の配列
- `T::Hash` : ハッシュ型を示し、キーと値の型を指定できる
  - 例：T::Hash[String, Integer] は文字列のキーと整数の値を持つハッシュ

## 3.型検査の厳しさは五段階
上の例では、ファイルの1行目に`# typed: true`と書いたように、マジックコメントを書くことで、**型検査の厳しさ**を指定できます。

参考: https://sorbet.org/docs/static 

# 終わりに
これまで半年間くらい型導入のプロジェクトに関わってきました。

私が感じたのは、**「静的型付けを導入することの難しさ」** です。
5年以上続いてきたプロダクトなのでサイズが大きく、新しく勉強することも多くありました。

今後も型に関していくつかの困難が待っていると思いますが、インシデントが起こりにくいプロダクト作りを進めていこうと思います。