ArticlesRuby programmingEngineering

Effective Sorbet

Tips, tricks, and things to avoid with the Ruby type-checker Sorbet.

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.

All new code should be 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.

Understand flow sensitivity

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>"

Use 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

Always use 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)

Don’t put methods / business logic on 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.

Make things exhaustive and use 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.

Use 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

Don’t use shapes

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.

Don’t include 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].

Get un-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.

Make almost everything final!

Sorbet docs on 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:

  • The method cannot be overridden or redefined.
  • The method cannot be mocked in tests. Method mocking is disallowed so people can't write bad and brittle tests mocking that method.
  • The Sorbet compiler can optimize this method call since it doesn’t need to account for potential dynamic dispatch.

Modules, methods, and classes which are not meant to be extended, overridden, or mocked (which is most good code) should be marked final.

Beware: hash literals are poorly typed

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 →

Use meaningful return types

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!