This gem is a Rails extension that enhances sorbet support in Rails.
Install the gem and add to the application's Gemfile by executing:
bundle add rails-on-sorbetIf bundler is not being used to manage dependencies, install the gem by executing:
gem install rails-on-sorbetIt is recommended to disable strict typechecking for this gem's RBI files generated by tapioca.
This can be done by adding these lines to sorbet/tapioca/config.yml:
gem:
typed_overrides:
rails-on-sorbet: "false"This gem adds additional signatures to Rails types and builtin Ruby types that make using sorbet easier in Rails projects.
It also adds some new utility types and modifies certain Rails DSLs to make it possible to strictly type methods defined through metaprogramming.
Rails and Ruby lack a unified type for representing datetime.
We have Time, DateTime and ActiveSupport::TimeWithZone which basically
cover really similar use cases but have no common ancestor.
This makes working with time incredibly frustrating.
We introduced a new utility type called Timelike to alleviate this burden.
Definition:
Timelike = T.type_alias { T.any(Time, DateTime, ActiveSupport::TimeWithZone) }Rails introduced two hashlike types: ActionController::Parameters, ActiveSupport::HashWithIndifferentAccess.
For the most part they behave like Hash but they don't inherit from it.
This causes a lot of issues in sorbet.
Because of that we introduced the Map interface.
It defines all methods that are common to Hash, ActionController::Parameters and ActiveSupport::HashWithIndifferentAccess.
Right now there is a bug in sorbet that causes the typechecker to hang and crash when you include a generic interface/module in Hash, Array, Set, Range etc.
So we couldn't implement Map directly in Hash :(
Instead, we introduced a casting function.
Example:
# Use the `Map` type in a signature
#
#: (Map[String, Integer]) -> void
def foo(m)
m["foo"] #=> Integer?
end
hash = { "foo" => 4 } #: Hash[String, Integer]
m = Map(hash) #=> Map[String, Integer]
foo(m) # OK
hash = { "foo" => 4 }.with_indifferent_access # => ActiveSupport::HashWithIndifferentAccess
m = Map(hash) #=> Map[String, untyped]
foo(m) # OK
params = ActionController::Parameters.new # => ActionController::Parameters
m = Map(params) #=> Map[String, untyped]
foo(m) # OKThere is a Map::Params alias for the result of converting ActionController::Parameters and
ActiveSupport::HashWithIndifferentAccess to Map: Map[String | Symbol, untyped].
Sorbet lacks proper generic handling of ActiveRecord::Relation.
The class itself is not generic and tapioca generates a bunch of classes like X::PrivateRelation, X::PrivateAssociationRelation, X::PrivateCollectionProxy per model but these classes have no common generic ancestors which makes them incredibly finnicky to work with.
Because of that we introduced a bunch of interfaces:
TypedRelationis included by per-model classes likeX::PrivateRelationTypedAssociation::Relationis included by per-model classes likeX::PrivateAssociationRelationTypedAssociation::CollectionProxyis included by per-model classes likeX::PrivateCollectionProxy
Example:
class Foo < ActiveRecord::Base
has_many :bars
end
foo = Foo.new
foo.bars #=> Bar::PrivateCollectionProxy
#: (TypedAssociation::CollectionProxy[Bar]) -> void
def do_smth(rel); end
do_smth(foo.bars) # ok!One common use case is working on child/parent model associations.
class Foo < ActiveRecord::Base
belongs_to :user
end
class Bar < Foo
belongs_to :manager
end
class User < ActiveRecord::Base
has_many :foos
end
class Manager < ActiveRecord::Base
has_many :bars
end
#: (TypedAssociation::CollectionProxy[Foo]) -> void
def do_smth_on_foos(rel); end
user = User.new
user.foos #=> Foo::PrivateCollectionProxy
do_smth_on_foos(user.foos) # ok!
user = Manager.new
user.bars #=> Bar::PrivateCollectionProxy
do_smth_on_foos(user.bars) # ok!This gem adds a new method called alias_association on ActiveRecord classes.
It lets you define aliases for getters and setters of belongs_to and has_one associations. There is also a tapioca compiler that makes sorbet aware of these aliases.
Example:
class Foo < ApplicationRecord
belongs_to :user
alias_association :owner, :user
end
f = Foo.last
f.owner == f.user #=> trueThis is a subclass of ActiveSupport::CurrentAttributes with tapioca/sorbet support.
New optional keyword arguments have been added to attribute:
type: the sorbet type of the getter/setterdoc: a docstring whose content will be used to generate a comment above the rbi signature created by tapioca
Example:
class Current < Rails::On::Sorbet::CurrentAttributes
attribute :session_counter, type: T.nilable(Integer), doc: <<~DOC
A counter that gets incremented when a new session is created.
DOC
endThe tapioca compiler will generate an RBI file like this:
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Current`.
# Please instead update this file by running `bin/tapioca dsl Current`.
class Current
include RailsOnSorbetCurrentAttributeMethods
extend RailsOnSorbetCurrentAttributeMethods
module RailsOnSorbetCurrentAttributeMethods
# A counter that gets incremented when a new session is created.
sig { returns(T.nilable(::Integer)) }
def session_counter; end
# A counter that gets incremented when a new session is created.
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def session_counter=(value); end
end
endTapioca now has the ability to generate strictly typed and accurate getters and setters for Rails serializers.
New optional keyword arguments have been added to ActiveRecord::Base::serialize:
return_type: the sorbet type returned by the gettersetter_type: the sorbet type of the parameter of the setterdoc: a docstring whose content will be used to generate a comment above the rbi signature created by tapioca
If no return_type or setter_type is given in the arguments, tapioca will try to call return_type and setter_type on the coder given to the serializer.
Example:
module IntegerCoder
class << self
def return_type = Integer
#: (String?) -> Integer?
def load(val)
val&.to_i
end
#: (Integer?) -> String?
def dump(val)
val&.to_s
end
end
end
class Foo < ActiveRecord::Base
serialize :bar, coder: IntegerCoder
serialize :baz, coder: YAML, return_type: T::Hash[String, String], doc: <<~DOC
Additional BAR data for finalizing orders.
DOC
endThe tapioca compiler will generate the following RBI file
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Foo`.
# Please instead update this file by running `bin/tapioca dsl Foo`.
class Foo
sig { returns(T.nilable(Integer)) }
def bar; end
sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
def bar=(value); end
# Additional BAR data for finalizing orders.
sig { returns(T.nilable(T::Hash[::String, ::String])) }
def baz; end
# Additional BAR data for finalizing orders.
sig { params(value: T.nilable(T::Hash[::String, ::String])).returns(T.nilable(T::Hash[::String, ::String])) }
def baz=(value); end
endAfter checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/espago/rails-on-sorbet.