2024年1月末に Steep-1.7.0.dev.1がひっそりとリリースされました。
今回は、このリリースで追加された ignore コメント機能 について紹介したいと思います。
なお、1.7.0.dev.1 というバージョンが示すように、この 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 コメントを使うとソースコードの一部でだけ型エラーを無視させることができます。
手元の中規模 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 のいくつかの機能は型チェックと相性が悪いため、いくつかの型エラーが発生していました。
たとえば、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 についてはまだまだ型エラーが多く発生しています。
手元で数多く発生しているのは、カラムの型を絞り込む(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 自身の問題のほかにも、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 は動的にクラスメソッドをはやしているため、 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 に変更提案を送っていますが、今のところは型エラーを無視するしかありません。
その他、細かいものとしては以下のようなものがありました。
!
を呼ぶとエラーになる Steep のバグ(?)の回避Integer#-(untyped)
が BigDecimal
と判定されてしまう問題の回避ignore コメント機能を使うと、個別の型エラーを無視することができるため、実プロジェクトで型チェックを有効にすることができます。
これまでは型定義を増やすことで型エラーを少しずつ減らしてきましたが、ignore コメント機能を使うことで、一気に型エラーを隠蔽することができました。
現状では回避が難しい問題や型を書くのに手がかかる箇所に ignore コメントを書くことで、型エラーのないコードを実現できます。
一方、現状の Steep は中規模の Rails アプリケーションを型チェックするためには、500件近くの ignore コメントを付ける必要がありました。
しかも、ActiveSupport や ActiveRecord といった中核の機能に対しても型エラーが発生してしまっているため、現状では型チェックを有効にするメリットよりも、型コメントを書く手間のほうが大きいと感じました。
型チェックを有効にした状態で新しいコードを書いてみましたが、そこでも ignore コメントを書く必要があり、少なくとも我々のプロジェクトではコードの書きやすい状態とは言えませんでした。
個人的には、Steepfile でのエラーレベルの調整と ignore コメントを組み合わせることで、開発体験を維持しつつ、型チェックのメリットを部分的でも享受することができないかと考えています。
(たとえば NoMethod は全体的に無視させつつ、それ以外のエラーを ignore で隠蔽するなど)
また、今回エラーを細分化していく中で、型チェッカや型抽出器、型リポジトリといったエコシステム側で改善できる部分はまだまだあると感じました。こうした改善も続けつつ、徐々に型チェックの範囲を広げていきたいと改めて感じました。
この記事では以下の内容を紹介しました。
最終的には ignore コメントを実コードにマージするところまでには至らなかったのですが、型システムの実用化が近づいていることを実感しました。
今後も型システムの進化に注目していきたいと思います。