ArticlesRuby programmingEngineering
Tips, tricks, and things to avoid with the Ruby type-checker Sorbet.
typed: strict
T::Struct
liberally
T::Enum
liberally
T::Enum.try_deserialize
for untrusted enum
inputs
NilClass
in your union or type
alias
T.untyped
ASAP
final!
I have written Ruby code with Sorbet since 2018. Over the time, I put considerable effort into learning how to leverage Sorbet to its fullest. I've fought it, complained about it, and wouldn't write Ruby code without it. These are my lessons learned.
Sorbet can’t model complex meta-programming and other more clever dynamic features of Ruby. The lack of meta-programming is made up for by code generation tools. You’ll find that code explicitly needs to be designed around “making Sorbet happy” and this limits how code and APIs can be designed pretty dramatically.
All pages of Sorbet's own documentation are required reading.
typed: strict
Sorbet type-checking is enabled for a Ruby file using a
typed:
sigil which look like one of these at the
time of the file:
# typed: false
# typed: true
# typed: strict
# typed: strong
You can read about the
difference between these modes, but know that for most code you'll struggle to achieve
typed: strong
as even a lot of Sorbet itself isn't
up to that caliber of safety (mostly due to lacking generics).
So we aim for the best we can and the minimum floor for good
code is typed: strict
.
This can’t be stressed enough. There’s no better contribution
you can make to code health during your day to day work than to
be writing at least typed: strict
code.
And this doesn’t just mean brand new features, but also work on existing code. If I am building out new methods that will be exercised for the first time (adding types could break things so we can’t go wild) I cut a new file just for that few set of methods so they can be written strict. Having Sorbet’s help far outweighs minor code locality concerns.
The #1 source of questions by far about Sorbet are centered around flow sensitivity.
Read about this how works and really absorb it. It breaks my
heart to see unnecessary T.must
and
T.cast
calls. These are suppose to be used
exceptionally. The majority of times I legitimately need them, I
also put a comment there to explain why.
In some cases, there are just way better methods available to use. For example,
T.must(Model.find_by_id(id))
#=> raises "unexpected nil"
Should use an available method which gives a much better error message:
Model.find_by_id!(id)
#=> raises "record not found for id: <id>"
T::Struct
liberally
Sorbet's T::Struct
is Ruby’s Clojure map: use them
everywhere. Don’t invent new data structures just for your use
case, leverage T::Struct
. Because
T::Struct
initializers are automatically created,
there’s no better alternative with less boilerplate.
If you know the fixed set of keys in the Hash, make a
T::Struct
instead. Dealing with old code where
hashes are being passed around? For your new code create a
hash_to_my_struct
method and then start passing the
struct around for new code.
Don’t use Shapes.
It is an experimental feature. There are a bunch of nasty quirks
about the implementation and no immediate plans to fix them.
They are also super verbose as every field must be specified
even if it is nil
.
Don’t lean on tuples for returning multiple values, use a struct instead to name all the values:
a, b, c = f(x)
# versus
r = f(x)
r.to_addresses
r.cc_addresses
r.bcc_addresses
const
over prop
It is incredibly rare for me to need my structs to have setters.
Defaulting to const
tells the reader that no one is
mutating the struct as it flows through a program. Good to know!
If you want to make a copy of a struct with only some of the
fields changed, instead of falling back to
prop
use:
new_struct = struct.with(new_props)
T::Structs
It’s important to decouple data from business logic. Great code is referentially transparent functions passing around primitives, standard library types, and structs.
T::Enum
liberally
To really put Sorbet to work helping you write code, you'll want to master exhaustiveness.
These constructs are really powerful tools. I love making use
especially of T::Enum
. I just sprinkle
T.absurd
and then next time I need to add something
I can just follow the type errors. This is a huge aid,
especially for developer experience, that with sufficient
planning you can make the addition/removal of features almost
brain-dead game-play.
Sealed classes are quite limited but they can work wonders that simple Enums can’t.
T::Enum.try_deserialize
for untrusted enum
inputs
If you try to deserialize an enum value like so, you’ll get a
KeyError
:
begin
MyEnum.deserialize('this_is_not_an_enum_value')
rescue KeyError
# input didn't match an enum value
end
Instead reach for try_deserialize
It’s much less
boilerplate and returns a T.nilable(MyEnum)
so
Sorbet can help you handle the invalid case:
enum = MyEnum.try_deserialize('this_is_not_an_enum_value')
if !enum
# input didn't match an enum value
end
Shapes are a
massively broken, experimental feature of Sorbet. Any place you
want to use a shape reach for a T::Struct
instead.
Beyond not working, shapes are much less ergonomic than structs
because you have to specify values for all hash keys, even the
nil-able keys.
NilClass
in your union or type
alias
Consider these type aliases:
AnyOfIncludingNil = T.type_alias do
T.any(TypeFoo, TypeBar, NilClass)
end
NilableAnyOf = T.type_alias do
T.nilable(T.any(TypeFoo, TypeBar))
end
While these are valid aliases, they aren’t the greatest for usability because they have coupled nil-ability to the type. The better way to define this type:
FooOrBar = T.type_alias {T.any(TypeFoo, TypeBar)}
# Then use T.nilable in context with:
sig {params(foo_or_bar: T.nilable(FooOrBar)).void}
def perform(foo_or_bar); end
"A FooOrBar may not be provided here" vs "A FooOrBar can be nil."
This becomes more obvious as you use type aliases more places.
Often you'll want to handle the nil
case and then
move on to a method that doesn't need to handle that anymore.
For that new method, since you've coupled nil-ness to the type
alias you can't reuse it.
Nil-ability is about when: When it is not nil? When is it required? When is it optional? Bolting nil into a type, we can’t really say. For more on this train of thought check out Maybe Not [talk].
T.untyped
ASAP
In strict mode you can still be dealing with code which is
T.untyped
. Some of this is unavoidable but you
definitely want to minimize your usages of
T.untyped
and more importantly minimize spreading
it around.
Quickly take that T.untyped
'd thing and make a new
typed thing to pass around:
class GoodData < T::Struct
const :foo, String
end
sig {params(bad_data: T.untyped).returns(GoodData)}
def self.make_good_data(bad_data)
GoodData.new(
foo: bad_data.foo
)
end
Avoid letting those T.untyped
value leak into other
pieces of code, it makes refactoring much harder in the future.
final!
One part of writing good code is ensuring it can’t be abused in
ways it didn’t intend to be. This is doubly important for APIs
shared across package boundaries. But Ruby doesn’t do much to
help us guard by default, for example private
and
private_class_method
annotations are needed to hide
methods from other calls. Less obvious abuse can be done via
inheritance, for example:
module MyPackage
module MyModule
sig {returns(String)}
def self.make_string
"string"
end
end
end
Seems like good code: module instead of class, static methods accepting/returning data. However someone can do this:
module MyOtherPackage
module UnrelatedModule
extend Opus::MyPackage::MyModule
sig {returns(String)}
def self.make_string
"other-string"
end
end
end
And people can start using UnrelatedModule
in place
of MyModule
. Effectively MyModule
is
being used as a mix-in and mix-ins are bad.
Well, we didn’t intend for that… (and almost never intend for
that). Luckily Sorbet gives us a helpful annotation
final!
which prevents this extension:
module Opus::MyPackage
module MyModule
extend T::Helpers
final!
sig(:final) {returns(String)}
def self.make_string
"string"
end
end
end
Now Sorbet will guard against that for us. sorbet.run example →
You’ll notice make_string
's signature now has this
sig(:final)
on it. When final!
is used
all methods in the module/class need to be
sig(:final)
. And sig(:final)
can also
be used a-la-carte on any method to prevent it from being
overridden.
Marking a method as final has great benefits:
Modules, methods, and classes which are not meant to be extended, overridden, or mocked (which is most good code) should be marked final.
Consider:
my_hash = {a: 1, b: 2, c: 3}
This hash is clearly T::Hash[Symbol, Integer]
but
Sorbet does not agree. The hash will have the shape
T::Hash[T.untyped, T.untyped]
unless you explicitly
cast it.
Sorbet run link →
sig {params(x: Integer).returns(T.nilable(String))}
Sometimes we’ll overuse nil
in return types, which
makes sense: it’s pretty easy to do even by accident. However,
when working through things, sprinkling
T.nilable
all over the place, we start to lose
actionable context. What does a nil
return value
mean? If you’re asking this question, reach for a richer, more
readable type:
class NoMatchingResponseCode; end
sig {params(x: Integer).returns(T.any(String, NotMatchingRespo
nseCode))}
Woah, I like get what this sig is for now.
This isn’t clear cut as some things are obvious, but especially when you may be returning from multiple places, it’s good to document the subtle concerns. One cool thing about this is testing! Consider:
def self.build_thing(field_a, field_b)
if field_a.empty?
return nil
end
if !valid?(field_b)
return nil
end
Thing.new(field_a, field_b)
end
It’s hard to tell why were are returning nil from just calling the code. However if we write:
def self.build_thing(field_a, field_b)
if field_a.empty?
return FieldAEmpty.new
end
if !valid?(field_b)
return InvalidFieldB.new
end
Thing.new(field_a, field_b)
end
We can write sick tests like this to ensure we’re actually testing the proper branches:
assert_instance_of(FieldAEmpty, build_thing(...))
assert_instance_of(InvalidFieldB, build_thing(...))
For those more complex/unclear things, reach for something
better than T.nilable
!