Static Typing Without a Full Rewrite

Ruby’s dynamic nature gives developers flexibility, but it also shifts the burden of knowing what a method returns or what a hash contains onto whoever is reading or modifying the code. On Shopify’s App Store Ads team, we adopted Sorbet to bring static type checking into our Ruby repository. The benefits we found apply to any Ruby project, especially as it grows in size and number of contributors.

Methods That Document Themselves

Consider a simple call like action.value.to_h. If action can return nil, this line will raise an undefined method error at runtime. Without a unit test covering that specific case, the bug ships. Worse, if foo() is overridden in a subclass with a different return type, there is nothing in the calling code to hint that the result needs different handling.

Adding Sorbet method signatures changes that picture completely. A signature stating that action returns a Result object or nil makes the contract explicit. Sorbet then rejects action.value.to_h as invalid at static check time, because it can see the potential nil. If the type check passes, you know the method returns the types you expect—no need to write trivial unit tests for every branch or search the codebase to infer what a variable holds.

Structured Data Instead of Loose Hashes

Passing around hashes with keys like :id, :score, and :state is a common source of bugs. The hash gives no guarantees: :id might not exist until a record is persisted, :state might be intended as an enum but accept any string, and :end_date might be nil while :start_date never is. The developer is expected to remember every variant and the context in which each is valid.

Sorbet replaces those implicit rules with explicit typed structs. For an ad creation flow, we define an Input::Ad struct for data coming from an API request and a Database::Ad struct for the persisted record, where the latter extends the former by adding :id and :score. The types now state clearly:

  • :id and :score exist on database records but not on input objects.
  • :state must be a State object, implemented via Sorbet enums, so invalid strings fail at check time.
  • :end_date can be nil, but :start_date cannot.

Any code that violates these rules produces a static type error, and any developer can see exactly what fields are available at each layer of the stack. The same principle extends beyond our internal layers; we use GraphQL to define type contracts between services, which ensures API data parses cleanly into these typed objects.

Interfaces for Polymorphism and Injection

Our repository follows a hexagonal architecture with dependency injection. Incoming requests compose a task by injecting the necessary adapters, then execute it. This pattern keeps components isolated and testable, but it requires explicit contracts between pieces. Without them, you might call Action.perform with either a SynchronousIndexer or an AsynchronousIndexer, both implementing an index method differently—one writing to a database immediately, the other enqueuing a job. Nothing guarantees both classes have the same method signature or return the same result type.

Sorbet interfaces close that gap. We define an Indexer module declaring that the index method takes an array of keyword strings and returns a Result object with a list of errors. Both indexer classes include this module, and Action.perform is typed to accept an Indexer. The type checker now verifies that any injected adapter honors the contract, making the design pattern safe to rely on.

Rolling Out Type Checking Gradually

Rewriting an entire codebase before adopting Sorbet would be impractical. Sorbet supports gradual typing, working on a file-by-file basis. Five strictness levels let a team choose how much checking to apply and where. On our team, we refactor namespace by namespace: when a GitHub issue touches a set of files in the same namespace, we raise those files to the true strictness level, adding signatures, interfaces, enums, and structs as needed.

Enforcing type safety has made our code more consistent with our design patterns. It catches errors that unit tests miss and prevents unsafe code from reaching production. The cost of adding annotations is outweighed by the clarity and confidence they provide.