1. Type-safe Method Calls

Let’s take a look at an example.

“”
def action
result, errors = foo()
return nil unless errors.empty?
result
end

action.value.to_h
“”

On the last line we call the method action and then call value.to_h on its return type. If action returns nil, calling value.to_h will cause an undefined method error.

Without a unit test covering the case when action returns nil, such code could go by undetected. To make matters worse, what if foo() is overridden by a child class to have a different return type? When types are inferred from the names of variables such as in the example, it is hard for any new developer to know that their code needs to handle different return types. There is no clue to suggest what result contains, so the developer would have to search the entire code base for what it could be.

Let’s see the same example with method signatures.

“”
sig { returns(T.nilable(Result)) }
def action
result, errors = foo()
return nil unless errors.empty?
result
end

action.value.to_h
“”

In the revised example, it’s clear from the signature that action returns a Result object or nil. Sorbet type checking will raise an error to say that calling action.value.to_h is invalid because action can potentially return nil. If Sorbet doesn’t raise any errors regarding our method, we deduce that foo() returns a Result object, as well as an object (most likely an array) that we can call empty? on. Overall, method annotations give us additional clarity and safety. Now, instead of writing trivial unit tests for each case, we let Sorbet check the output for us.

2. Type-safety for Complex Data Types

When passing complex data types around, it’s easy to use hashes such as the following:

“”
ad = {
:id => 1,
:name => “Email Marketer”,
:state => “Running”,
:keywords => nil,
:start_date => date,
:end_date => nil,
:score, 1.5
}
“”

This approach has a few concerns:

  • :id and :score may not be defined properties until the object is created in the database. If they’re not properties, calling ad.id or ad.score on the ad object will return nil, which is unexpected behavior in certain contexts.
  • :state may be intended to be an enum. There are no runtime checks that ensure that a value such as running isn’t accidentally put in the hash.
  • :start_date has a value, but :end_date is nil. Can they both be nil? Will the :start_date always have a value? We don’t know without looking at the code that generated the object.

Situations like this put a large onus on the developer to remember all the different variants of the hash and the contexts in which particular variants are used. It’s very easy for a developer to make a mistake by trying to access a key that doesn’t exist or assign the incorrect value to a key. Fortunately, Sorbet helps us solve these problems.

Consider the example of creating an ad:

Creating an ad

Input data flows from an API request to the database through some layers of code. Once stored, a database record is returned.

“”
module Input
class Ad < T::Struct
const :name, String
const :state, State
const :keywords, T::Array[String]
const :start_date, Date
const :end_date, T.nilable(Date)

end
end

module Database
class Ad < T::Struct
const :ad, Input::Ad
const :id, Integer
const :score, Float

delegate :name, :state, :keywords, :start_date, :end_date, to: :ad

end
end
“”

Here we define typed Sorbet structs for the input data and the output data. A Database::Ad extends an Input::Ad by additionally having an :id and :score.

Each of the previous concerns have been addressed:

  • :id and :score clearly do not exist on ads being sent to the database as inputs, but definitely exist on ads being returned.
  • :state must be a State object (as an aside, we implement these using Sorbet enums), so invalid strings cannot be assigned to :state.
  • :end_date can be nil, but :start_date will never be nil.

Any failure to obey these rules will raise errors during static type checking by Sorbet, and it is clear to developers what fields exist on our object when it’s being passed through our code.

To extend beyond the scope of this article, we use GraphQL to specify type contracts between services. This lets us guarantee that ad data sent to our API will parse correctly into Input::Ad objects.

#sorbet #code

Writing Better, Type-safe Code with Sorbet
3.60 GEEK