Slide 1

Slide 1 text

Sorbetの型が RailsのMVC全てを 貫通するまで Sorbetの型が RailsのMVC全てを 貫通するまで 関ケ原Ruby会議01

Slide 2

Slide 2 text

自己紹介 源 村田 信濃掾 構幾寿郎 一真 みなもとの むらた しなののじょう かじくすろう かずま 長野県長野市出身 @kazzix14 TwoGate Inc.

Slide 3

Slide 3 text

Sorbet / Steep で Ruby に型がつくようになった 具体的にRailsのどこに型がついたか…?

Slide 4

Slide 4 text

型がついている場所とついていない場所 ✓ Model, Pure Ruby — Sorbet + Tapioca で守れる ⚠ Controller ✗ View ✗ Browser Model Lib Validator PORO ↓ ここから先、型が切れる ↓ action内は型あり params / 返り値は T.untyped ERB の中の Ruby は Sorbet の対象外 別言語 別世界

Slide 5

Slide 5 text

基本的な部分

Slide 6

Slide 6 text

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 ) ホバー・補完・定義ジャンプ・診断 静的(エディタ)

Slide 7

Slide 7 text

T.absurd — 全パターン処理の強制 T::Enum を case で分岐。 T.absurd は「ここに到達するはずがない」を Sorbet に宣言する。

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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 — 公開アノテーション集を取得

Slide 10

Slide 10 text

Tapioca で Model に型がつく tapioca dsl が ActiveRecord のカラム・関連の型を自動生成 → product.price は Integer

Slide 11

Slide 11 text

params にも型をつけたい Rails の params の中身はSorbet から見ると T.untyped sig { void } def create foo = params[:foo] # => T.untyped foo[:bar] # => T.untyped end

Slide 12

Slide 12 text

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 の変換を自動で行う

Slide 13

Slide 13 text

Model ✓ Controller ✓ Sorbet M Model Lib, etc. ✓ C Controller ✓ V View B Browser ここまでは既存ツールの組み合わせでできる。 問題は ここから先。

Slide 14

Slide 14 text

ERB には型がつかない Sorbet は ERB 中の Ruby を型チェックしない

<%= @product.name %>

<%= @product.price %> 円

在庫: <%= @product.stock %> 個

Slide 15

Slide 15 text

ERBでSorbetが動くようにした typo メソッド補完 @ivar 補完

Slide 16

Slide 16 text

どうやっているのか ERB Ruby Type Check 用の Ruby 抽出 → @product も helper も未定義 Controllerの型情報を付与 → 実際のERBと同じ状況でtype checkできる

Slide 17

Slide 17 text

Step 1 — ERB から Ruby を抽出 元の ERB 抽出された Ruby Herbで解析 → <% %> 内の Ruby だけを取り出す

<%= @product.name %>

<%= @product.price.to_s %> 円

在庫: <%= @product.stock %> 個

<% @orders.each do |order| %> <%= order.engraving || "—" %> <% end %> @product.name @product.price.to_s @product.stock @orders.each do |order| order.engraving || "—" end

Slide 18

Slide 18 text

抜き出しただけでは型チェックできない @product link_to @product.name @orders.each do |order| order.engraving end link_to " 詳細", path 型が無い 存在しない 評価環境が無い = 型チェックにならない

Slide 19

Slide 19 text

Controllerから型を持ってくる 抽出した Ruby(View) purchase_controller.rb # @product の型がほしい @product.name def index # ここで Product に決まる @product = T.must(Product.first) end

Slide 20

Slide 20 text

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($5: T.nilable(Product)) # ^^^^^^^ Sorbet が推論した @product の型が変数に乗っている { "app/views/purchase/index": { "@product": "Product" } }

Slide 21

Slide 21 text

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 の中身が型チェックされる

Slide 22

Slide 22 text

LSP エディタ sv lsp(Proxy) Sorbet LSP sv lsp が Sorbet の LSP をラップ。エディタは ERB、Sorbet は生成 .rb を見る。 .erb を編集 ⇄ SourceMap でソースコード上の位置を変換 ⇄ 生成 .rb を見る ERB 上で ホバー・補完・定義ジャンプ・型エラー が、普通の .rb と同じように動く

Slide 23

Slide 23 text

View 層 ✓ Sorbet sorbet_view で View にも型が届いた M Model Lib, etc. ✓ C Controller ✓ V View ✓ B Browser

Slide 24

Slide 24 text

Model ✓ Controller ✓ View ✓ Browser だけまだ型がついていない Sorbet M Model Lib, etc. ✓ C Controller ✓ V View ✓ B Browser

Slide 25

Slide 25 text

フロントもRubyで書けば、 型がつくのでは? フロントもRubyで書けば、 型がつくのでは? フロントもRubyで書けば、 型がつくのでは?

Slide 26

Slide 26 text

Ruby WASM ブラウザ上で Ruby がそのまま動く

Slide 27

Slide 27 text

デモアプリ 0:00

Slide 28

Slide 28 text

共有するファイルの中身 名入れバリデーション。 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

Slide 29

Slide 29 text

1ファイルを両方で読む ブラウザ:ERB で埋め込み → ruby.wasm で eval .rbにしておくことでsorbetがtype checkできる サーバー: validates_with で使う 同じ engraving_validator.rb を、ブラウザ(ruby.wasm)とサーバー( validates_with )の両方で使う <%= raw File.read( "app/validators/engraving_validator.rb") %> 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

Slide 30

Slide 30 text

fetchにも型をつける リクエスト クライアントで CreateParams.new(...).serialize → サー バーで CreateParams.try_from_hash レスポンス サーバーの T::Struct → serialize → JSON → ブラウザで try_from_hash で復元 fetch でやり取りするなら、リクエスト・レスポンスも同じ仕組みで型を通せる 実装だけでなく、型も共有できる!

Slide 31

Slide 31 text

Browser 層 ✓ Sorbet 全レイヤー点灯 M Model Lib, etc. ✓ C Controller ✓ V View ✓ B Browser ✓

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

リポジトリ Mangrove sorbet_view srb-lens github.com/kazzix14/mangrove github.com/kazzix14/sorbet_view github.com/kazzix14/srb-lens

Slide 34

Slide 34 text

ありがとうございました @kazzix14