SorbetTyped::Props
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.13149tapioca < 0.19.1+sorbet-runtime < 0.6.12698— tapioca < 0.19.1 references T::Module which was added to sorbet-runtime in 0.6.12698tapioca >= 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'
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
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'
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')
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
::newrather than#initializeinPhlex::SGML, so Sorbet cannot statically validate props on Phlex components. CallingHelloWorld.newwithout required props will not raise a Sorbet error. See issue #23.
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
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
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. # => "xyz"
my_object.my_private_prop = 'baz' # => Non-private call to private method `my_private_prop=` on `MyClass`
my_object.baz
my_object. # => "baz"
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.