Concern クラスの included ブロックの型エラーを解消する


2024年 05月 16日

ひとつ前の記事では Steep に追加予定の ignore コメント機能を紹介しました。

その記事の中で、ActiveSupport::Concern で提供されている included ブロックのことを 解消の難しい型エラー と紹介しましたが、ブログ記事にまとめていく最中に解決方法を思いつきました。

問題の整理

そもそもどういう問題だったのかを改めて整理します。

手元のコードでは、concern 系クラスの included ブロックで型エラーが起きていました。included ブロック内では、 include 先のクラスとして コードが実行されるのですが、Steep はこのブロック内で self が切り替わっていることが認識できず、Concern クラスとして判定を進めようとするため、型エラーが発生します。

module Loginable
  extend ActiveSupport::Concern

  included do
    helper_method :foo  #=> Type `singleton(::Loginable)` does not have method `helper_method`
  end
end

上記の例では singleton(::Loginable) にメソッドがないと言っていますよね。

前回の記事では、この問題は解消が難しいので、ignore コメントで型エラーを黙らせることにしました。

解決方法

実は上の「問題の整理」のところに書いたことにヒントがあります。

「include 先のクラスとしてコードが実行される」、「このブロック内で self が切り替わっている」という部分です。 つまり、ブロック内で self が切り替わっていることを Steep にヒントとして与えてあげれば、型エラーが解消するというわけです。

実際には、次のようなアノテーションコメントを書きます。

module Loginable
  extend ActiveSupport::Concern

  included do
    # @type self: singleton(ActionController::Base)
    helper_method :foo
  end
end

こうやってアノテーションコメントを書くことで、Steep に対して「このブロック内では self を include 先のクラスである ActionController::Base クラスとして扱うように」と伝えることができます。

アノテーションコメント

Steep には、このように型情報を補足するためのアノテーションコメントが用意されています。RBS では表現できない、ブロック単位での型情報や変数の型情報などを Ruby のソースコード内にコメントとして記述します。

Ruby では DSL や宣言的な記述が好まれ、コンテキストの切り替わるブロックがあちこちにありますが、現在提供されている型でコンテキストスイッチが表現されていない場合は、今回のようにアノテーションコメントを併用するのがよさそうです。

また、メソッド内、ブロック内の変数の型情報などを補うのも有用です。

前回の記事で #present? による型の絞り込みができないという話をしましたが、これもアノテーションコメントを使うことで解消できます。

num = [1, 2, 3, nil].sample  #=> Integer?
if num.present?
  # @type var num: Integer
  num.succ  #=> #present? では型が絞り込まないが、アノテーションコメントによって Integer と扱われるため、型エラーは発生しない
end

理想としては型の進化で改善してほしいところですが、adhoc な対応として、アノテーションコメントを使って回避する手もあります。

アノテーションコメントを多用するとコードが読みづらくなるという問題もありますが、型エラーを解消する手段のひとつとして覚えておくとよいでしょう。

理想の解決方法

今回はアノテーションコメントで対応することにしましたが、理想の解決方法も考えてみました。

ActiveSupport::Concern の型に型変数を追加して、次のような定義にするのがよさそうです。受け取った T を使い、include ブロックの self に singleton(T) という型を与えています。

module ActiveSupport::Concern[T]
  def included: (?untyped? base) { () [self: singleton(T)] -> void } -> void
end

こうすることで included ブロックの self を、Concern クラスごとに調整できるようになります。

たとえば、コントローラ系の Concern クラスの型定義では、以下のように ActiveSupport::Concern の型引数に ActionController::Base を指定します。これにより、included ブロック内の self が自動的に (アノテーションコメントなしで) ActionController::Base と認識されるようになります。

module MyConcern
  extend ActiveSupport::Concern[ActionController::Base]
end

この提案は理想系のひとつではないかと考えましたが、すでに ActiveSupport::Concern の型は世界中で利用されています。そのため、型変数を追加してしまうと世界中のあちこちの型を書き換えが発生してしまいます。かなりドラスティックな変更で、導入に勇気が必要ですね。

このアイディアは ruby-jp slack に投稿してみたものの、実際に導入されるかどうかはわかりません。

まとめ

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

  • アノテーションコメントの紹介
  • アノテーションコメントを使うと included ブロックの型エラーが解消できること
  • 型変数を使った理想の解決方法

Happy ruby-typing life!