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).

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'

Phlex Example

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

  prop :name, String

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

Instance Variables

If you need more instance variables beside defined props, define them within the class body, not the initializer.

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

  prop :my_prop, Integer

  @my_var = T.let(nil, T.nilable(String))

  sig { void }
  def foo
    T.reveal_type(my_prop) # => Integer
    T.reveal_type(@my_var) # => T.nilable(String)
  end
end

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. Define them within the class body instead.

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"

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.