Phlex::Sorbet
Type-safe Props for Phlex views and components, powered by sorbet-schema.
Quick Example
class UserCard < Phlex::HTML
include Phlex::Sorbet
class Props < T::Struct
const :user_id, Integer
const :show_email, T::Boolean, default: false
end
def view_template
div do
span { "User ##{user_id}" }
span { "email visible" } if show_email
end
end
end
UserCard.new(user_id: 1).call # => "<div><span>User #1</span></div>"
UserCard.new(user_id: 1, show_email: true).call # => "<div>...<span>email visible</span></div>"
UserCard.new(user_id: "1").call # OK — coerced via sorbet-schema
UserCard.new(user_id: "abc").call # => raises Phlex::Sorbet::InvalidPropsError
UserCard.new # => raises Phlex::Sorbet::InvalidPropsError (missing user_id)Features
-
Direct prop access — use
user_idinsideview_templateinstead ofprops.user_id. -
Type safety — props are validated against your
PropsT::Structat instantiation time. - Coercion — strings from controller params are coerced to the declared type via sorbet-schema.
-
Nested
T::Structprops — fully supported via sorbet-schema. -
Optional Props — components can omit the
Propsclass when they take no props. -
Backward-friendly accessor —
props.user_idstill works. -
RSpec matchers —
have_prop,have_props,accept_props,reject_props. -
Tapioca DSL compiler — generates RBI for
initializeand prop accessors.
Installation
bundle add phlex-sorbetOr:
gem install phlex-sorbetUsage
Basic component with props
class Button < Phlex::HTML
include Phlex::Sorbet
class Props < T::Struct
const :label, String
const :variant, Symbol, default: :primary
end
def view_template
button(class: "btn btn-#{variant}") { label }
end
end
Button.new(label: "Save").call
Button.new(label: "Cancel", variant: :secondary).callComponent without props
The Props constant is optional. Components without props work as usual:
class Spinner < Phlex::HTML
include Phlex::Sorbet
def view_template
div(class: "spinner")
end
end
Spinner.new.callComplex types
class TagList < Phlex::HTML
include Phlex::Sorbet
class Props < T::Struct
const :tags, T::Array[String]
const :filters, T::Hash[String, T.untyped], default: {}
end
def view_template
ul do
tags.each { |t| li { t } }
end
end
end
TagList.new(tags: ["ruby", "phlex"]).callNested T::Struct props
class Greeting < Phlex::HTML
include Phlex::Sorbet
class User < T::Struct
const :name, String
const :email, String
end
class Props < T::Struct
const :user, User
const :show_email, T::Boolean, default: false
end
def view_template
p { "Hi #{user.name}" }
p { user.email } if show_email
end
end
Greeting.new(user: Greeting::User.new(name: "Ada", email: "ada@example.com")).callUsing the props accessor
If you'd rather access props through the struct, the props reader is always available:
class Card < Phlex::HTML
include Phlex::Sorbet
class Props < T::Struct
const :title, String
end
def view_template
h2 { props.title }
end
endCoercion (string → typed value)
Because sorbet-schema handles deserialization, props passed as strings (e.g. from controller params) are coerced to the declared type:
UserCard.new(user_id: "42") # user_id == 42
ToggleSwitch.new(enabled: "true") # enabled == trueIf coercion fails, Phlex::Sorbet::InvalidPropsError is raised.
RSpec matchers
In your spec_helper.rb or rails_helper.rb:
require "phlex/sorbet/rspec"The matchers are auto-included into RSpec.
have_prop
expect(UserCard).to have_prop(:user_id)
expect(UserCard).to have_prop(:user_id, Integer)
expect(UserCard).to have_prop(:show_email, T::Boolean).with_default(false)have_props
expect(UserCard).to have_props(user_id: Integer, show_email: T::Boolean)
expect(UserCard).to have_props(:user_id, Integer).and_prop(:show_email, T::Boolean)accept_props
expect(UserCard).to accept_props(user_id: 1)
expect(UserCard).to accept_props(user_id: 1, show_email: true)reject_props
expect(UserCard).to reject_props(user_id: "abc")
expect(UserCard).to reject_props(user_id: "abc")
.with_error(Phlex::Sorbet::InvalidPropsError)Example test
RSpec.describe UserCard do
describe "Props" do
it { is_expected.to have_prop(:user_id, Integer) }
it { is_expected.to have_prop(:show_email, T::Boolean).with_default(false) }
it { is_expected.to accept_props(user_id: 1) }
it { is_expected.to reject_props(user_id: "abc") }
end
it "renders the user id" do
expect(UserCard.new(user_id: 7).call).to include("User #7")
end
endTapioca DSL compiler
This gem ships a Tapioca DSL compiler that generates RBI files describing each component's initialize signature and per-prop accessors.
bundle exec tapioca dslFor a component:
class UserCard < Phlex::HTML
include Phlex::Sorbet
class Props < T::Struct
const :user_id, Integer
const :show_email, T::Boolean, default: false
end
endIt generates RBI like:
class UserCard
sig { returns(Integer) }
def user_id; end
sig { returns(T::Boolean) }
def show_email; end
sig { params(user_id: Integer, show_email: T::Boolean).void }
def initialize(user_id:, show_email: T.unsafe(nil)); end
end
Phlex::Kit support
When you collect components into a Phlex::Kit module, Phlex defines an instance and singleton method for every component constant under the kit (e.g. Card { ... } or Button(label: "Save")). Sorbet otherwise can't see these methods and reports them as undefined.
This gem ships a second Tapioca DSL compiler — Tapioca::Dsl::Compilers::PhlexKit — that detects every module that extend Phlex::Kit and emits an RBI entry for each registered component. With bundle exec tapioca dsl re-run after adding/removing components, calls like:
class Dashboard < Components::Base
def view_template
Card { "hello" }
Button(label: "Save")
end
end…type-check cleanly under Sorbet.
License
MIT.