Steep-1.7.0.dev.1 の ignore コメントを試す


2024年 05月 02日

2024年1月末に Steep-1.7.0.dev.1がひっそりとリリースされました。

今回は、このリリースで追加された ignore コメント機能 について紹介したいと思います。

なお、1.7.0.dev.1 というバージョンが示すように、この ignore コメントはまだ正式リリースされていません。そのため、正式にリリースされる際には仕様が変わる可能性があります。その点をご理解いただきつつ、この記事をお読みください。

ignore コメントとは

これまで、Steep の型エラーの制御は Steepfile の設定で行っていました。
Steepfile による設定はアプリケーション全体に対して反映されるため、このファイルだけ無視したい、この行だけ無視したいといったピンポイントでのエラー制御はできませんでした。

1.7.0 で導入される予定の ignore コメントは、行単位もしくはブロック単位で型エラーを無視できるという便利機能です。

たとえば、行単位で型エラーを無視する場合は、行末に # steep:ignore というコメントを書きます。

foo(1, 2, 3) # steep:ignore

特定の型エラーだけを無視したい場合は、コメントの後にエラー種別を書きます。

foo(1, 2, 3) # steep:ignore NoMethod

ちなみに、行単位の ignore コメントの場合、ignore コメントの前後に別のコメントを書くことはできません。

foo(1, 2, 3) # steep:ignore # ここにコメントを書くと ignore コメントが効かない
bar(1, 2, 3) # ここに書いてもダメ # steep:ignore

# 普通のコメントと ignore コメントを併用したい場合は、普通のコメントを別の行に書く必要があります。
baz(1, 2, 3) # steep:ignore

また、ブロック単位で型エラーを無視する場合は、対象の行を # steep:ignore:start# steep:ignore:end で囲みます。

# steep:ignore:start
foo(1, 2, 3)
bar(4, 5, 6)
baz(7, 8, 9)
# steep:ignore:end

なお、ブロック単位でエラーを無視する場合は、エラー種別を指定することはできません。

このように、ignore コメントを使うとソースコードの一部でだけ型エラーを無視させることができます。

実コードに ignore コメントを導入してみた

手元の中規模 Rails アプリには型エラーが 500件ほどありました (strict モード)。

$ bundle exec steep check
# Type checking files:

(略)

Detected 490 problems from 145 files

これらの型エラーを ignore コメントを使うことですべて無視し、エラーなしの状態にできました。

$ bundle exec steep check
# Type checking files:

(略)

No type error detected. 🫖

※ エラーの少ない lenient モードを基準にしようと考えたのですが、VSCode 拡張では steep check コマンドと異なり、lenient モードであっても多くのエラーを表示してしまうため、実際にエラー検査を有効化したときの動作であろう strict モードで計測しました。

 

エラーを無視する ignore コメントを使ったらエラーが 0件になりました、という報告だけだと面白くないですよね。ですので、ここからは ignore コメントを使う必要があった箇所をいくつか紹介します。

ActiveSupport の表現への対応不足

ActiveSupport のいくつかの機能は型チェックと相性が悪いため、いくつかの型エラーが発生していました。

たとえば、class_attribute を使うとクラス属性(へのアクセサメソッド)が定義されます。しかし、RBS 上ではクラス属性に対する型情報が用意されておらず、型エラーが発生していました。

class Foo
  class_attribute :foo

  def bar
    puts(foo)     #=> NoMethod
    self.foo = 1  #=> NoMethod
  end
end

これは型を手書きすれば回避できる問題でもありますが、ひとまず ignore コメントで型エラーを黙らせることにしました。型抽出器で自動抽出させるアプローチで解決するのが良さそうだという考えです。

また、ActiveSupport まわりだと、#blank?#present? による型の絞り込みにも対応していません。

num = [1, 2, 3, nil].sample  #=> Integer?
if num
  num.succ  #=> 通常の if 文や #nil? では型が絞り込まれるため、このブロックの中では Integer とみなされる。そのため、この呼び出しは valid
end
if num.present?
  num.succ  #=> #present? では型が絞り込まれず、引き続き Integer? とみなされるため NoMethod になる
end

この他、ActiveSupport::Concern で提供されている included ブロックでも型エラーが発生しがちです。
included ブロック内のコードは include 先のクラスとして実行することになるのですが、型チェック時にはその情報が抜け落ちているため、Concern クラスにメソッドがないと型エラーが発生します。

module Loginable
  extend ActiveSupport::Concern

  included do
    helper_method :foo  #=> NoMethod
  end
end

ActiveRecord に関する型エラー

ActiveRecord についてはまだまだ型エラーが多く発生しています。

手元で数多く発生しているのは、カラムの型を絞り込む(type narrowing)ことができないことによるエラーです。

user = User.find(1)
user.age         # DBスキーマに基づいて Integer? と定義されている

if user.age      # if 文で age 属性の型を絞り込んでいるが…
  user.age.succ  # Steep は Integer ではなく Integer? として扱うため NoMethod
end

我々のプロジェクトで利用している rbs_rails は、DB スキーマの情報をもとに以下のような型定義を出力します。

class User < ::ApplicationRecord
  module GeneratedAttributeMethods
    ...
    def age: () -> Integer?
    ...
  end
end

Steep はこの定義に従い、 User#age の型を 常に Integer? として扱います。
型の世界では、何回か呼び出すと nil を返す可能性があると解釈されているのです。 Array#shift などがいい例ですね。

numbers = [1, 2]
numbers.shift  #=> 1
numbers.shift  #=> 2
numbers.shift  #=> nil

この性質により、ActiveRecord モデルのカラムは型の絞り込みの恩恵を受けることができません。
if 文で user.age が Integer だと判定されても、if ブロック内では user.age は nil を返す可能性があると推測され、 Integer? とみなされます。
そのため、optional なカラムは型エラーが頻発します。

ちなみに、これはメソッドだから起こり得る問題です。一時的に変数に代入をすると意図通り型が絞り込まれます。

age = user.age
if age
  age.succ  #=> OK
end

ここではカラムについて紹介しましたが、has_one などの関連についても同様の問題が確認されています。

ActiveRecord 拡張系の 3rd party gem の型定義不足

ActiveRecord 自身の問題のほかにも、discard や kaminari、draper などの 3rd party gem によって ActiveRecord に追加されたメソッドに対して、
rbs_gem_collection などの型リポジトリで型を提供する方法がありません。

そのため、以下のようなコードはいずれも型エラーが発生します。

User.page(1)  #=> NoMethod

user = User.find(1)
user.discard  #=> NoMethod
user.decorate  #=> NoMethod

個別のモデルごとに型定義を手書きすることで回避はできますが、かなりの手間になるため、現状では一律 ignore コメントを使っています。

こうした gem の型を書きやすくする変更を rbs_rails に提案しているので、将来的にはこの問題が解消されることを期待しています。

ActionMailer の型エラー

ActionMailer は動的にクラスメソッドをはやしているため、 rbs prototype で型を生成することができません。

アプリケーションで定義するメールクラスは次のようなものですが…

class UserMailer < ApplicationMailer
  def notification(user)
    @user = user

    mail from: SYSTEM_MAIL_ADDR, to: user.mail, subject: "Hello world"
  end
end

実際に利用する際はこういうふうに呼びますよね。

UserMailer.notification(user)

この UserMailer.notification の型を用意する必要があるのです。

rbs_rails に変更提案を送っていますが、今のところは型エラーを無視するしかありません。

その他

その他、細かいものとしては以下のようなものがありました。

  • boolish 型のデータに対して ! を呼ぶとエラーになる Steep のバグ(?)の回避
  • 必ず値のある配列に対して first をしている箇所
  • Integer#-(untyped)BigDecimal と判定されてしまう問題の回避
  • helpers の型エラー問題

感想

ignore コメント機能を使うと、個別の型エラーを無視することができるため、実プロジェクトで型チェックを有効にすることができます。
これまでは型定義を増やすことで型エラーを少しずつ減らしてきましたが、ignore コメント機能を使うことで、一気に型エラーを隠蔽することができました。
現状では回避が難しい問題や型を書くのに手がかかる箇所に ignore コメントを書くことで、型エラーのないコードを実現できます。

一方、現状の Steep は中規模の Rails アプリケーションを型チェックするためには、500件近くの ignore コメントを付ける必要がありました。
しかも、ActiveSupport や ActiveRecord といった中核の機能に対しても型エラーが発生してしまっているため、現状では型チェックを有効にするメリットよりも、型コメントを書く手間のほうが大きいと感じました。
型チェックを有効にした状態で新しいコードを書いてみましたが、そこでも ignore コメントを書く必要があり、少なくとも我々のプロジェクトではコードの書きやすい状態とは言えませんでした。

個人的には、Steepfile でのエラーレベルの調整と ignore コメントを組み合わせることで、開発体験を維持しつつ、型チェックのメリットを部分的でも享受することができないかと考えています。
(たとえば NoMethod は全体的に無視させつつ、それ以外のエラーを ignore で隠蔽するなど)

また、今回エラーを細分化していく中で、型チェッカや型抽出器、型リポジトリといったエコシステム側で改善できる部分はまだまだあると感じました。こうした改善も続けつつ、徐々に型チェックの範囲を広げていきたいと改めて感じました。

まとめ

この記事では以下の内容を紹介しました。

  • Steep-1.7.0 には ignore コメント機能が追加される見込み
  • ignore コメントの使い方
  • 実プロジェクトに型コメントを導入しようとした事例

最終的には ignore コメントを実コードにマージするところまでには至らなかったのですが、型システムの実用化が近づいていることを実感しました。

今後も型システムの進化に注目していきたいと思います。