Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Sorbetの型がRailsのMVC全てを貫通するまで

Avatar for kazzix kazzix
May 29, 2026
62

 Sorbetの型がRailsのMVC全てを貫通するまで

関ケ原Ruby会議01

Avatar for kazzix

kazzix

May 29, 2026

Transcript

  1. 型がついている場所とついていない場所 ✓ Model, Pure Ruby — Sorbet + Tapioca で守れる

    ⚠ Controller ✗ View ✗ Browser Model Lib Validator PORO ↓ ここから先、型が切れる ↓ action内は型あり params / 返り値は T.untyped ERB の中の Ruby は Sorbet の対象外 別言語 別世界
  2. Sorbet 単体でできること 機能 できること チェック sig 引数・返り値に型をつける 静的 + 実行時

    T::Struct / T::Enum 型つきの値・列挙を定義 静的 + 実行時 T.let / T.must / T.cast 型注釈・nil 除去・キャスト 静的 + 実行時 T.absurd case の網羅性チェック 静的 ジェネリクス T::Array[T] ・ type_member など 静的 abstract! / interface! 抽象クラス・インターフェース 静的 + 実行時 フロー解析 is_a? / nil チェックで型を絞る 静的 LSP ( srb tc --lsp ) ホバー・補完・定義ジャンプ・診断 静的(エディタ)
  3. Mangrove::Enum データ( type で形が変わる) 生の Hash で利用 → T.untyped Mangrove::Enum

    で定義 型つきで利用 → 確定 標準の T::Enum は名前の集合。Mangrove の Enum はバリアントごとに値を持てる { "type": "person", "data": { "name": "Alice", "age": 30 } } { "type": "book", "data": { "title": "1984", "author": "Orwell" } } case res["type"] when "book" res["data"]["title"] # => T.untyped end class MyApiResponse extend Mangrove::Enum variants do variant Person, Person variant Book, Book end end case res when MyApiResponse::Person res.age # => Integer when MyApiResponse::Book res.title # => String end
  4. Tapiocaがやること ActiveRecordは動的にメソッドを生やす tapioca dsl が生成する RBI 動的に生えるメソッドの .rbi を自動生成する product.price

    # price が見えない # sorbet/rbi/dsl/product.rbi class Product sig { returns(Integer) } def price; end sig { returns(Integer) } def stock; end end tapioca gem — 依存 gem の API を RBI 化 tapioca dsl — ActiveRecord 等の動的メソッドを RBI 化 tapioca annotations — 公開アノテーション集を取得
  5. params にも型をつけたい Rails の params の中身はSorbet から見ると T.untyped sig {

    void } def create foo = params[:foo] # => T.untyped foo[:bar] # => T.untyped end
  6. 1. アクションごとに T::Struct を定義 2. ApplicationController で自動パース class CreateParams <

    T::Struct const :name, String const :age, Integer end sig { params(params: CreateParams).void } def create(params) params.name # => String params.age # => Integer end # params → T::Struct に変換して # アクションの引数として渡す def send_action(name, *) parsed = _param_klass .try_from_hash(params.to_unsafe_h) super(name, parsed.ok_inner) end # アクション名から Params クラスを探す def _param_klass self.class.const_get( "#{action_name.camelcase}Params" ) end try_from_hash が String→Integer・Hash→Enum の変換を自動で行う
  7. Model ✓ Controller ✓ Sorbet M Model Lib, etc. ✓

    C Controller ✓ V View B Browser ここまでは既存ツールの組み合わせでできる。 問題は ここから先。
  8. ERB には型がつかない Sorbet は ERB 中の Ruby を型チェックしない <h1><%= @product.name

    %></h1> <p><%= @product.price %> 円</p> <p> 在庫: <%= @product.stock %> 個</p>
  9. どうやっているのか ERB Ruby Type Check 用の Ruby 抽出 → @product

    も helper も未定義 Controllerの型情報を付与 → 実際のERBと同じ状況でtype checkできる
  10. Step 1 — ERB から Ruby を抽出 元の ERB 抽出された

    Ruby Herbで解析 → <% %> 内の Ruby だけを取り出す <h1><%= @product.name %></h1> <p><%= @product.price.to_s %> 円</p> <p> 在庫: <%= @product.stock %> 個</p> <% @orders.each do |order| %> <%= order.engraving || "—" %> <% end %> @product.name @product.price.to_s @product.stock @orders.each do |order| order.engraving || "—" end
  11. 抜き出しただけでは型チェックできない @product link_to @product.name @orders.each do |order| order.engraving end link_to

    " 詳細", path 型が無い 存在しない 評価環境が無い = 型チェックにならない
  12. srb-lens: Sorbet の推論結果を回収する srb-lens がやること CFG を Rust でパースして @product

    → Product を取り出す 出力 srb tc --print=cfg-text で Sorbet の内部表現(CFG)をダンプ。推論済みの型が乗っている。 # srb tc --print=cfg-text (PurchaseController#index 、抜粋) @product$4: Product = T.must(<tmp>$5: T.nilable(Product)) # ^^^^^^^ Sorbet が推論した @product の型が変数に乗っている { "app/views/purchase/index": { "@product": "Product" } }
  13. Step 2 — 抜き出した型と抽出したRubyを合体する Step 1 で抽出した Ruby 生成される .rb(Sorbet

    がチェック) @product.name @product.price.to_s @product.stock @orders.each do |order| order.engraving || "—" end { "app/views/purchase/index": { "@product": "Product", "@orders": "Order::PrivateRelation" } } class Generated::Purchase::Index::Html < ::ActionView::Base include FooHelper def initialize # Ruby として実行するわけではないので、型がつけば値はなんでも良い @product = T.let(T.unsafe(nil), Product) @orders = T.let(T.unsafe(nil), Order::PrivateRelation) end def __sorbet_view_render @product.name @product.price.to_s @product.stock @orders.each do |order| order.engraving || "—" end end end これを srb tc に通せば、ERB の中身が型チェックされる
  14. LSP エディタ sv lsp(Proxy) Sorbet LSP sv lsp が Sorbet

    の LSP をラップ。エディタは ERB、Sorbet は生成 .rb を見る。 .erb を編集 ⇄ SourceMap でソースコード上の位置を変換 ⇄ 生成 .rb を見る ERB 上で ホバー・補完・定義ジャンプ・型エラー が、普通の .rb と同じように動く
  15. View 層 ✓ Sorbet sorbet_view で View にも型が届いた M Model

    Lib, etc. ✓ C Controller ✓ V View ✓ B Browser
  16. 共有するファイルの中身 名入れバリデーション。 sig つき・ Result 型を返す、ただの Ruby。これ1ファイルをサーバーとブラウザで共有する。 # app/validators/engraving_validator.rb class

    EngravingValidator extend T::Sig sig { params(p: Personalization).returns(Mangrove::Result[NilClass, EngravingError]) } def self.validate_personalization(p) if p.is_a?(Personalization::Engraving) return Result::Err.new(EngravingError::Empty) if p.inner.strip.empty? return Result::Err.new(EngravingError::TooLong) if p.inner.length > MAX_LENGTH return Result::Err.new(EngravingError::InvalidCharacters) unless p.inner.match?(PATTERN) end Result::Ok.new(nil) end end
  17. 1ファイルを両方で読む ブラウザ:ERB で埋め込み → ruby.wasm で eval .rbにしておくことでsorbetがtype checkできる サーバー:

    validates_with で使う 同じ engraving_validator.rb を、ブラウザ(ruby.wasm)とサーバー( validates_with )の両方で使う <script type="text/ruby" id="rb-validator"> <%= raw File.read( "app/validators/engraving_validator.rb") %> </script> vm.eval( document.getElementById("rb-validator").textContent ) class Order < ApplicationRecord validates_with EngravingValidator end class EngravingValidator < ActiveModel::Validator def validate(record) # 共有ファイルの同じメソッドを呼ぶ self.class.validate_personalization( record.personalization_enum ) end end
  18. fetchにも型をつける リクエスト クライアントで CreateParams.new(...).serialize → サー バーで CreateParams.try_from_hash レスポンス サーバーの

    T::Struct → serialize → JSON → ブラウザで try_from_hash で復元 fetch でやり取りするなら、リクエスト・レスポンスも同じ仕組みで型を通せる 実装だけでなく、型も共有できる!
  19. Browser 層 ✓ Sorbet 全レイヤー点灯 M Model Lib, etc. ✓

    C Controller ✓ V View ✓ B Browser ✓
  20. Model → Controller → View → Browser Model → Controller

    → View → Browser Sorbet M Model Lib, etc. ✓ C Controller ✓ V View ✓ B Browser ✓ Sorbet の型が、全てを貫通した