【Ruby 3.0】自分で書いたライブラリに型定義を追加してみたメモ

# はじめに
Ruby 3.0で型定義が導入されました。
[Ruby 3\.0\.0 リリース](https://www.ruby-lang.org/ja/news/2020/12/25/ruby-3-0-0-released/)

実際に既存のコードに追加するときの手順を確認してみたかったので、ちょうど以前勉強用に作成したライブラリ(Gem)が規模も小さく導入も簡単そうだったので、これに型定義を追加して、実際にGem利用側が型定義を使うまでの流れを確認してみました。
[kuredev/simple\_ping: Simpe Ping Client for Ruby](https://github.com/kuredev/simple_ping)

大体は以下の記事の流れを参考に、各ステップで確認したことや調べたこと等をまとめていきます。
[Ruby 3\.0の静的型定義をTypeScriptみたいにライブラリに書いてみた所感 \- Narazaka::Blog](https://narazaka.hatenablog.jp/entry/2020/12/26/Ruby_3.0%E3%81%AE%E9%9D%99%E7%9A%84%E5%9E%8B%E5%AE%9A%E7%BE%A9%E3%82%92TypeScript%E3%81%BF%E3%81%9F%E3%81%84%E3%81%AB%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%81%AB%E6%9B%B8%E3%81%84%E3%81%A6#rbs%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E4%BD%9C%E3%82%8B%E6%96%B9%E6%B3%95)

# ライブラリに型定義を追加する
## 自動でRBSを作成

まず初めに型定義を追加していくため、RBSファイルを作成する必要があります。１から手で作るのは大変なので、大まかなものをまず自動で作成することを考えます。
自動でRBSの作成には大きく3つの方法がありますが、

- rbs prototype rb
- rbs prototype runtime
- TypeProf

できれば引数/戻り値の型情報も自動で作成してほしかったので、まずは TypeProf の利用を試みました。

### 参考

[RubyからRBSを生成する各方法の特徴 \- pockestrap](https://pocke.hatenablog.com/entry/2020/12/18/230235)

[【Ruby 3\.0】RBSの生成方法を色々試してみるメモ \- memo\.log](https://kure.hatenablog.jp/entry/2021/01/01/010415)

### TypeProfでの試み

ところが、実際に typeprof で型定義を出力しようとすると以下のエラーが発生してしまいました。

```ruby
% typeprof lib/simple_ping/client.rb 
# Analysis Error
A constant `MonitorMixin' is used but not defined in RBS
```

メッセージ的にはRBSに `MonitorMixin` モジュールの定義が無いと表示されています。
ライブラリ中では [logger](https://docs.ruby-lang.org/ja/latest/library/logger.html) ライブラリを使用していますが、この中の `Logger::LogDevice` が `MonitorMixin` を利用しているようでした。

以下のような簡単なコードに対して typedef してもやはり同様のエラーとなったため、再現性がありそうでした。

あまり深入り出来ていませんが、Ruby標準ライブラリの多くは既にRBSの型定義情報が提供されていますが、 `MonitorMixin` の定義はまだ無いために typeprof でも型の解釈に失敗してしまったのかなと思います。

```ruby:tmp.rb
require "logger"

class Kure
  def self.run
    logger = Logger.new
    logger.info "hoge"
  end
end
```

### rbs prototype runtime による試み

すぐには解決出来なそうだったので、今回は `rbs prototype runtime` によってRBSを準備することとしました。
`rbs prototype rb` も試しましたが、形式やコメントの有無等で `rbs prototype runtime` の方が結果がシンプルだったので、こちらを採用しました。

`rbs prototype runtime` でRBSを作成する場合はプログラムを実行するコードが必要なので、以下のコードをリポジトリのトップに準備し、

```ruby:sample_run.rb
require_relative "./lib/simple_ping"

ping_client = SimplePing::Client.new(src_ip_addr: "172.31.7.56")
puts ping_client.exec(dest_ip_addr: "8.8.8.8")
```

以下のように実行します。

```sh
% sudo rbs prototype runtime -R sample_run.rb "SimplePing::*"
```

<details><summary>実行結果</summary><div>

```ruby
class SimplePing::Client
  public

  def exec: (dest_ip_addr: untyped, ?data: untyped) -> untyped

  private

  def initialize: (src_ip_addr: untyped, ?log_level: untyped) -> untyped

  def logger: () -> untyped

  def socket: () -> untyped
end

SimplePing::Client::TIMEOUT_TIME: Integer

class SimplePing::ICMP
  public

  def data: () -> untyped

  def data=: (untyped) -> untyped

  def id: () -> untyped

  def id=: (untyped) -> untyped

  def is_type_destination_unreachable?: () -> untyped

  def is_type_echo?: () -> untyped

  def is_type_echo_reply?: () -> untyped

  def is_type_redirect?: () -> untyped

  def seq_number: () -> untyped

  def seq_number=: (untyped) -> untyped

  def successful_reply?: (untyped icmp) -> untyped

  def to_trans_data: () -> untyped

  def type: () -> untyped

  def type=: (untyped) -> untyped

  private

  def carry_up: (untyped num) -> untyped

  def checksum: () -> untyped

  def gen_data: () -> untyped

  def gen_id: () -> untyped

  def gen_seq_number: () -> untyped

  def initialize: (type: untyped, ?code: untyped, ?id: untyped, ?seq_number: untyped, ?data: untyped) -> untyped
end

SimplePing::ICMP::TYPE_ICMP_DESTINATION_UNREACHABLE: Integer

SimplePing::ICMP::TYPE_ICMP_ECHO_REPLY: Integer

SimplePing::ICMP::TYPE_ICMP_ECHO_REQUEST: Integer

SimplePing::ICMP::TYPE_ICMP_REDIRECT: Integer

class SimplePing::RecvMessage
  public

  def code: () -> untyped

  def data: () -> untyped

  def id: () -> untyped

  def seq_number: () -> untyped

  def to_icmp: () -> untyped

  def type: () -> untyped

  private

  def initialize: (untyped mesg) -> untyped
end
```

</div>
</details>

### 自動作成結果の確認

上記結果をRBSファイルにして `sig` ディレクトリに格納し、 `steep check` で型情報に問題が無いか確認してみます。

```zsh:steep準備
% gem install steep
% mkdir sig
% vim sig/simple_ping.rbs # 上記結果をペースト
% steep init
% vim Steepfile # 下記記載
target :lib do
  signature "sig"
  check "lib"
  check "./"
end
```

```zsh:実行
% steep check
sig/simple_ping.rbs:1:0...13:3  UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:17:0...61:3 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:71:0...89:3 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:15:0...15:41        UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:63:0...63:60        UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:65:0...65:47        UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:67:0...67:49        UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:69:0...69:45        UnknownTypeNameError: name=::SimplePing
```

自動で作成した型定義情報なので成功してほしいところですが、失敗してしまっています。 `::SimplePing` が無い（コードでは定義しているがRBSファイルの中では定義していなかった）と怒られてしまいました。これも含めて修正をしていくと都度エラーが発生したため、いくつかの点でRBSファイルを手動で修正していきます。（rbs 生成時のコマンドの引数等で回避できるエラーもいくつかあるかもしれません）

## 手動で修正

今回は以下の通り修正してみました。

- 上3行は記載しないとエラーとなってしまったので、記載しました。
- 下4行は `SimplePing::Clinet` クラスのコンストラクタとメソッドです。 rbs コマンドで生成した情報は基本的に引数/戻り値の型が untyped となりますが、この Gem のユーザーが直接的に利用するこのクラスは引数の型等を書いておきたいと思ったので untyped から修正して仕様の型に修正しました。

```diff:simple_ping.rbs
+ module SimplePing
+ end
+ SimplePing::VERSION: String
- def initialize: (src_ip_addr: untyped, ?log_level: untyped) -> untyped
+ def initialize: (src_ip_addr: String, ?log_level: Integer) -> void
- def exec: (dest_ip_addr: untyped, ?data: untyped) -> untyped
+ def exec: (dest_ip_addr: String, ?data: String) -> bool
```

改めて steep check し、問題無く動作することを確認しておきます

```zsh:実行
% steep check
# 何も出力されなければOK
```

## 動作確認

試しにわざと誤ったコードを書いてみて、型チェックが動作するか確認してみます。
上述で準備した実行用のファイルを以下のように修正してみます。

```ruby:sample_run.rb
require_relative "./lib/simple_ping"

ping_client = SimplePing::Client.new(src_ip_addr: 100) # 本来 "IP Address" で指定する部分をInteger
puts ping_client.exec(dest_ip_addr: 100) # 本来 "IP Address" で指定する部分をInteger
```

steep check してみると、型定義情報にしたがって誤っていることを指摘してくれました。

```zsh
% steep check 
sample_run.rb:3:50: IncompatibleAssignment: lhs_type=::String, rhs_type=::Integer (100)
  ::Integer <: ::String
   ::Numeric <: ::String
    ::Object <: ::String
     ::BasicObject <: ::String
==> ::BasicObject <: ::String does not hold
sample_run.rb:4:36: IncompatibleAssignment: lhs_type=::String, rhs_type=::Integer (100)
  ::Integer <: ::String
   ::Numeric <: ::String
    ::Object <: ::String
     ::BasicObject <: ::String
==> ::BasicObject <: ::String does not hold
```

ちなみにVS CodeにSteepのプラグインを入れておくとコードを書いている途中でも指摘してくれます。便利ですね！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50690/2c562dbf-0b73-0cf5-c205-6e909db794ff.png)

# 外からGemをインストールして、追加した型定義を使ってみる

## リリース

作成した定義情報を入れてリリースします。
[Ruby 3\.0の静的型定義をTypeScriptみたいにライブラリに書いてみた所感 \- Narazaka::Blog](https://narazaka.hatenablog.jp/entry/2020/12/26/Ruby_3.0%E3%81%AE%E9%9D%99%E7%9A%84%E5%9E%8B%E5%AE%9A%E7%BE%A9%E3%82%92TypeScript%E3%81%BF%E3%81%9F%E3%81%84%E3%81%AB%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%81%AB%E6%9B%B8%E3%81%84%E3%81%A6#rbs%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E4%BD%9C%E3%82%8B%E6%96%B9%E6%B3%95) 記事でも記載されている通り、 `sig` ディレクトリに定義ファイルを入れておけば利用する際に自動で読み込んでくれるようなので、作成したRBSファイルを入れてリリースします。

```zsh
% git add sig/simple_ping.rbs
% git commit -m "xxx"
% [以下リリース作業...]
```

## 外から利用

別の環境で、リリースしたGemをインストールして利用してみます。
Bundler で入れたライブラリの型定義を参照する場合は、Bundlerで入れたSteepを実行する必要があるようだったので、SteepもBundlerで入れておきます。

```zsh
% bundle init
% vim Gemfile # 以下を追記。Versionは適宜読み替えること
gem "simple_ping", "0.1.1"
gem "steep"
% bundle install --path vendor/bundle
```

インストールしたらsteep の準備をします。

```zsh
% bundle exec steep init
% vim Steepfile
target :lib do
  signature "sig"
  check "./"
  library "simple_ping"
end
```

`library "simple_ping"` のように記載するとBundlerでインストールした先のsig配下のRBSを読み込んでくれるようです。
この環境で、Gemを利用したコードを、先程のようにあえて間違った形で書いてみます。

```ruby:sample_run.rb
require "simple_ping"

SimplePing::Client.new(src_ip_addr: 1) #  本来は文字列で指定するところをIntegerで指定
```

`steep check` を実行してみます。
型情報の誤りを指摘してくれました！
画像は省略しますが、ちゃんとVS Code上でも警告してくれます。

```zsh
% bundle exec steep check
sample_run.rb:3:36: IncompatibleAssignment: lhs_type=::String, rhs_type=::Integer (1)
  ::Integer <: ::String
   ::Numeric <: ::String
    ::Object <: ::String
     ::BasicObject <: ::String
==> ::BasicObject <: ::String does not hold
```

以上です、お疲れさまでした。

# その他参考記事

[Rubyで型チェック！動かして理解するRBS入門 〜サンプルコードでわかる！Ruby 3\.0の主な新機能と変更点 Part 1〜 \- Qiita](https://qiita.com/jnchito/items/bf8c6c2e1dd6cff05f4e)

[【Ruby】シンプルなPingクライアントを作ってみた \- Qiita](https://qiita.com/kure/items/2bd68ad810ac291c3365)