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

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

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for kazzix kazzix
May 29, 2026
12

 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 の型が、全てを貫通した