SorbetTyped::Props

Gem Version

An extension of sorbets native props syntax, to make it usable and fully typed in any class. Mainly provides a tapioca dsl compiler to generate the initializer signature when using props outside of T::Struct.

You can use the T::Struct props and const syntax to define attributes on any class.

This should make it easier to create classes with a set of attributes they should be initialized with. Inspiration was the integration of literal properties into phlex, which doesn't really work with sorbet, if you want to have everything fully typed (and not use two runtime type-systems in parallel).

Tested with

This gem is tested against all combinations of the following dependency versions.

Tested versions
Ruby 3.3, 3.4, 4.0
sorbet-runtime 0.6.12997, 0.6.13321
tapioca 0.17, 0.18, 0.19
sorbet 0.6.12997, 0.6.13321

Excluded combinations

Some version combinations are incompatible with each other independently of this gem — users depending on those combinations would face the same conflict in their own projects. These are excluded from the test matrix:

  • tapioca < 0.19.0 + sorbet-runtime >= 0.6.13149 — tapioca < 0.19 calls has_rest on T::Private::Methods::Signature which was removed in sorbet-runtime 0.6.13149
  • tapioca < 0.19.1 + sorbet-runtime < 0.6.12698 — tapioca < 0.19.1 references T::Module which was added to sorbet-runtime in 0.6.12698
  • tapioca >= 0.19.1 + sorbet-runtime < 0.6.12698 — tapioca >= 0.19.1 enforces sorbet-runtime >= 0.6.12698 in its gemspec; listed explicitly so the test generator skips sorbet-runtime < 0.6.12698 rather than treating the bundler rejection as an unexpected error

Installation

Install the gem and add to the application's Gemfile by executing:

bundle add sorbet_typed-props

If bundler is not being used to manage dependencies, install the gem by executing:

gem install sorbet_typed-props

Usage

Include the SorbetTyped::Props module in any class you want to have T::Struct-like attributes:

class MyClass
  include SorbetTyped::Props

  prop :my_prop, String
end

my_object = MyClass.new(my_prop: 'foo')

my_object.my_prop # => "foo"
my_object.my_prop = 'bar'

Full documentation

Defaults and optional props

Use default: for a static fallback value or factory: for a freshly constructed default per instance:

class Config
  include SorbetTyped::Props

  const :timeout_seconds, Integer, default: 30
  const :logger, Logger, factory: -> { Logger.new($stdout) }
end

Config.new                      # uses defaults
Config.new(timeout_seconds: 60) # override specific prop

Full documentation

Internal props

Use extra: { initializer: false } for attributes that are set after construction, not at it — avoiding T.must noise at read sites for order-dependent implementations:

class Pipeline
  include SorbetTyped::Props

  const  :input,  String
  prop   :result, String, extra: { initializer: false }

  def run
    self.result = process(input)
  end

  private

  def process(value) = value.upcase
end

pipeline = Pipeline.new(input: 'hello')
pipeline.result  # => TypeError("Property `result` of `Pipeline` was read before being set")
pipeline.run
T.reveal_type(pipeline.result)  # => String
pipeline.result  # => 'HELLO'

Full documentation

Inheritance

Props are inherited by subclasses. The Tapioca compiler generates a combined initialize signature for each concrete class:

class Animal
  include SorbetTyped::Props

  prop :name, String
end

class Dog < Animal
  prop :breed, String
end

Dog.new(name: 'Rex', breed: 'Labrador')

Full documentation

Phlex integration

class Components::HelloWorld < Phlex::HTML
  extend T::Sig
  include SorbetTyped::Props

  prop :name, String

  sig { void }
  def view_template
    h1 { "Hello, #{name}!" }
  end
end

Known limitation: Phlex overrides ::new rather than #initialize in Phlex::SGML, so Sorbet cannot statically validate props on Phlex components. Calling HelloWorld.new without required props will not raise a Sorbet error. See issue #23.

Full documentation

Extending initializer logic

If you want to implement some logic on instance initialization, implementing a custom initializer method would require you to write a lot of boilerplate code to redefine the method signature and instance variable initialization normally done by SorbetTyped::Props.

To allow you to nonetheless extend initialization, SorbetTyped::Props implements the overridable method post_props_initialize. It gets called right after prop initialization and has access to all props to do validation or whatever you like. Override this method in your own class to implement whatever custom logic you need.

class MyClass
  extend T::Sig
  include SorbetTyped::Props

  prop :my_prop, Integer

  sig { override.void }
  def post_props_initialize
    raise 'invalid' if my_prop.negative?
  end
end

Full documentation

Note: you cannot do instance variable initialization here.

Method Visibility

If you want attributes in your initializer but not be part of your public class interface, you can use ruby's visibility modifiers. Unfortunately I found no better way and did not want to modify sorbet's prop syntax.

class MyClass
  extend T::Sig
  include SorbetTyped::Props

  prop :my_prop, Integer # reader and writer are public
  const :my_const, String # reader ist public, has no writer

  prop :prop_with_private_writer, String # reader is public, writer should be private
  private :prop_with_private_writer= # makes the writer private

  const :my_private_prop, String # reader and writer are private
  private :my_private_prop, :my_private_prop=

  sig { void }
  def foo
    self.prop_with_private_writer = 'foo'
  end

  sig { returns(String) }
  def bar
    self.my_private_prop
  end

  sig { void }
  def baz
    self.my_private_prop = 'baz'
  end
end

my_object = MyClass.new(my_prop: 1, my_const: 'my_const', prop_with_private_writer: 'abc', my_private_prop: 'xyz')

my_object.my_prop # => 1
my_object.my_prop = 2

my_object.my_const # => "my_const"
my_object.my_const = 'abc' # => Setter method `my_const=` does not exist on `MyClass`

my_object.prop_with_private_writer # => "abc"
my_object.prop_with_private_writer = 'foo' # => Non-private call to private method `prop_with_private_writer=` on `MyClass`
my_object.foo
my_object.prop_with_private_writer # => "foo"

my_object.my_private_prop # => Non-private call to private method `my_private_prop` on `MyClass`
my_object.bar # => "xyz"
my_object.my_private_prop = 'baz' # => Non-private call to private method `my_private_prop=` on `MyClass`
my_object.baz
my_object.bar # => "baz"

Full documentation

Putting prop-definition after an access modifier without arguments does not work:

class MyClass
  include SorbetTyped::Props

  private

  prop :my_prop, String # <= still public
end

Development

The project uses mise-en-place as development tool.

After checking out the repo, run mise run setup to install dependencies. Then, run mise test to run the tests. You can also run mise task ls for a list of available tasks.

RSpec is used as test suite. Spec files can and should be placed right beside their associated class files.

Contributing

Bug reports and pull requests are welcome on GitLab at gitlab.com/sorbet_typed/props.