Resolvendo problemas comuns de tipagem com RBS
Adicionar tipagem estática em uma aplicação Ruby on Rails pode parecer um desafio, especialmente quando usamos gems que fazem uso intenso de metaprogramação. Neste artigo, vamos explorar problemas reais que surgem ao aplicar RBS — como métodos de classe herdados via mixins, tipos nulos, generics e interfaces — e mostrar…
Adicionar tipagem estática em uma aplicação Ruby on Rails pode parecer um desafio, especialmente quando usamos gems que fazem uso intenso de metaprogramação. Neste artigo, vamos explorar problemas reais que surgem ao aplicar RBS — como métodos de classe herdados via mixins, tipos nulos, generics e interfaces — e mostrar como resolvê-los com boas práticas e exemplos práticos.
Métodos de classe vindos de um mixin com include
Um problema comum são os métodos estáticos adicionados por alguma gem e que não são triviais de tipar, por exemplo dado um Job comum do Sidekiq:
# app/jobs/import_job.rb
class ImportJob
include Sidekiq::Job
queue_as "critical"
def perform(filename)
# process the file...
end
end
Então inicialmente criamos o arquivo RBS da seguinte forma:
# sig/app/jobs/import_job.rbs
class ImportJob
include Sidekiq::Job
def perform: (String filename) -> void
end
Porém ao verificar o resultado no Steep recebemos o seguinte erro:
# Type checking files:
F
app/jobs/import_job.rb:4:2: [error] Type `singleton(::ImportJob)` does not have method `queue_as`
│ Diagnostic ID: Ruby::NoMethod
│
└ queue_as "critical"
~~~~~~~~
Detected 1 problem from 1 file

Mesmo com o include Sidekiq::Job
no RBS, o Steep não consegue saber de onde vem o método ao nível de classe queue_as
.
Fazendo uma busca com grep ou algo do tipo no projeto por def queue_as
encontramos o método em algum arquivo gerado pelo RBS Collection como o .gem_rbs_collection/sidekiq/7.0/job.rbs
ele está dentro do module Sidekiq::Job::ClassMethods
Parando para pensar, para recebermos via mixin valores a nível de classe o Ruby fornece o extend
, então como o Sidekiq consegue funcionar?
Olhando no código da gem https://github.com/sidekiq/sidekiq/blob/54ad22363358c47b3cfc5eef56ce632914360832/lib/sidekiq/job.rb#L50 podemos ver que ela faz um base.extend(ClassMethods)
, com isso não precisamos fazer o extend no arquivo Ruby, porém no RBS, para resolver somos obrigados a adicionar o extend
, ficando:
# sig/app/jobs/import_job.rbs
class ImportJob
include Sidekiq::Job
extend Sidekiq::Job::ClassMethods
def perform: (String filename) -> void
end
O mesmo ocorre com classes que usam o ActiveModel::Validations
e relacionados:
# app/models/client.rb
class Client
include ActiveModel::Model
include ActiveModel::Validations
attr_accessor :name, :email
validates :name, :email, presence: true
end
Arquivo RBS:
# sig/app/models/client.rbs
class Client
include ActiveModel::Model
include ActiveModel::Validations
attr_accessor name: String?
attr_accessor email: String?
end
Erro de método não encontrado pelo Steep

Injetando os métodos de classe do ActiveModel::Validations
# sig/app/models/client.rbs
class Client
include ActiveModel::Model
include ActiveModel::Validations
extend ActiveModel::Validations::ClassMethods
attr_accessor name: String?
attr_accessor email: String?
end
Como encontrar o tipo de um método
Como vimos anteriormente, para conseguir encontrar esses métodos de classe você deve fazer um grep no seu projeto para encontrar dentro do diretório .gem_rbs_collection
o método, com isso você vai encontrar em qual módulo ele está para fazer o extend
, pode citar o caso do sidekiq_options
Resolvendo valores nulos (entendendo os valores nilables do Active Record)
O Steep tem o sistema de flow-sensitive, então para cada variável que tem o tipo opcional, por exemplo String?
, ele avisará quando poderá o ocorrer um no method error for nil class
, esse é um dos bugs mais comuns, muitas vezes não é fácil saber se uma variável pode ser nula e podemos até checar isso de forma desnecessária, deixando o código mais confuso, o flow-sensitive
é uma das grandes vantagens de adicionar tipos estáticos. Vamos a um exemplo:
Voltando na classe Client
, digamos que precisamos de um método para retornar o mês de aniversário de um cliente para aplicar alguma regra de negócio como dar um desconto no seu mês de aniversário, então podemos fazer da seguinte forma:
def month_of_birth
birthday.month
end
Porém, o Steep vai nos lembrar que o tipo do atributo birthday
é Date?
, então pode ser nulo, assim instantaneamente no próprio editor vamos ser avisados. Esse tipo de erro parece ser muito bobo, mas acaba ocorrendo em produção vez ou outra, mesmo fazendo testes automatizados, não é incomum passar batido a verificação do valor nulo, podendo gerar uma grande insatisfação nos usuários da aplicação.

Com a maioria dos tratamentos guard clause
que fazemos normalmente no Ruby, o Steep já consegue entender que a variável não será mais nula:
def month_of_birth
return if birthday.nil?
birthday.month
end
def month_of_birth
unless birthday.nil?
birthday.month
end
end
def month_of_birth
birthday&.month
end
def month_of_birth
birthday or raise
birthday.month
end
É comum casos em que o tipo seja opcional, mas em determinado contexto o valor tenha que ser sempre presente, nesses casos é aconselhável usar o raise
para lançar uma exceção. Talvez até criar métodos com !
para indicar isso, por exemplo:
def birthday!
birthday or raise
end
Entendendo o conceito de Generics
Usando a gem Kaminari, é incluído vários métodos e scopes em models do Active Record, porém esses métodos não são incluídos corretamente pelo RBS Rails nos nossos models, para isso precisamos incluí-los manualmente, por exemplo o scope page
, o RBS Collecion gera a seguinte tipagem:
# .gem_rbs_collection/kaminari-activerecord/1.2/kaminari-activerecord.rbs
module ActiveRecord
class Base
module ClassMethods[Model, Relation, PrimaryKey]
def page: (?_ToI num) -> Relation
end
end
class Relation
module Methods[Model, PrimaryKey]
include Kaminari::ActiveRecordRelationMethods
include Kaminari::PageScopeMethods
def page: (?_ToI num) -> self
end
end
end
Então precisamos injetar esses métodos de classe na nossa classe Active Record User
, note que o ClassMethods
precisa receber 3 argumentos, aqui entra o conceito de tipos genéricos, o primeiro argumento é a classe do modelo, o segundo é a classe de Relation
, o terceiro é o tipo da primary key
, no caso o id, se fosse um uuid, o tipo seria String
.
# sig/app/models/user.rbs
class User < ApplicationRecord
extend ActiveRecord::Base::ClassMethods[::User, ::User::ActiveRecord_Relation, ::Integer]
end
Note que eu criei mais um arquivo para o user.rbs
, fora do diretório sig/rbs_rails
pois o arquivo gerado pelo RBS Rails (sig/rbs_rails/app/models/user.rbs
) sempre é sobrescrito quando rodamos o comando para atualizá-lo, então deixamos um arquivo para os tipos que incluímos manualmente e outro para o gerador automático (não sei conheço uma forma melhor de contornar isso, caso você conheça uma, sintasse encorajado para deixar nos comentários).
Interfaces para substituir o Ducktype
Duck Typing e Interfaces são formas de implementar polimorfismo em Ruby
🦆 Duck Typing (Ruby dinâmico)
“Se anda como um pato e grasna como um pato, é um pato.”
Ou seja, não importa a classe do objeto, desde que ele responda aos métodos esperados. Exemplo:
# arquivo Ruby
def print_name(obj)
puts obj.name
end
Essa função aceita qualquer objeto que tenha um método name
, independentemente da classe. Ducktype é muito flexível e poderoso, funciona muito bem com metaprogramação, porém é mais propenso a erros, já as interfaces são mais seguras, pois requerem a declaração dos métodos como um contrato formal, porém são mais burocráticas.
📜 Interfaces em RBS
Você pode declarar uma interface da seguinte forma:
# arquivo RBS
interface _Nominatable
def name: () -> String?
end
E usá-la em um método:
# arquivo RBS
def print_name: (_Nominatable obj) -> void
Agora, print_name
só aceita objetos que implementam um método name
que retorna String
.
Podemos usar a classe Client
, do exemplo anterior, que já implementa um atributo name
com isso é só incluir a interface no seu arquivo RBS
# sig/app/models/client.rbs
class Client
include _Nominatable
include ActiveModel::Model
include ActiveModel::Validations
extend ActiveModel::Validations::ClassMethods
attr_accessor name: String?
attr_accessor email: String?
end
Assim o Steep vai entender que um objeto Client
também é um _Nominatable
seguindo assim como o Ducktype o conceito de polimorfismo.
Tipagem por comentário inline
É possível tipar os métodos e variáveis por comentários no arquivo Ruby.
Métodos e variáveis locais:
# @type method build_contacts: () -> Array[String]
def build_contacts
contacts = [] # @type var contacts: Array[String]
contacts << "Fulano"
end
Não é possível escrever o tipo das variáveis locais pelos arquivos RBS, funciona somente pelos comentários inline no Ruby.
Variáveis de instância:
# @type ivar @address: String?
Segue a documentação com mais exemplos: https://github.com/soutaro/steep/blob/master/manual/annotations.md
Obs.: Existe uma gem em fase experimental para tipar por comentário seguindo a mesma sintaxe dos arquivos RBS, porém ainda está em fase experimental: https://github.com/soutaro/rbs-inline
Sorbet e RBS Inline
O Sorbet lançou uma funcionalidade experimental para usar a sintaxe do RBS para anotar os tipos do Sorbet. Isso parece ser um ótimo esforço afim de unificar os dois sistemas de tipos, porém ainda há diversas diferenças entre eles que precisam ser ajustadas. Segue a documentação: https://sorbet.org/docs/rbs-support
Tipando arquivos do frontend (ERB)
Infelizmente o Steep ainda não suporta arquivos ERBs, porém há planos para implementar isso https://github.com/soutaro/steep/issues/1409. Alternativamente, é possível usando gems como a Phlex https://github.com/yippee-fun/phlex ou Glimmer https://github.com/AndyObtiva/glimmer-dsl-web que transformam todas as tags HTML em métodos Ruby.
Tipos literais
Tipos literais permite atribuir valores como strings, números, símbolos, como tipos, por exemplo, é comum em Ruby aceitarmos símbolos ou strings como uma espécie de código de enum, então podemos garantir que só será aceito determinados valores:
# Arquivo RBS
type possible_status = :active | :pending | :inactive
# Arquivo RBS
class Foo
def change_status: (status: possible_status) -> void
end
No Ruby devemos só passar um status que esteja presente no possible_status
senão o Steep avisará que há um erro.
# Arquivo Ruby
Foo.new.change_status(status: :admin)

Conclusão
Ainda faltaram alguns tópicos de aplicação do RBS com Concerns, Scopes e Delegates do Rails, mas com o entendimento dos conceitos apresentados nesse artigo já é o suficiente para aplicar a tipagem nesses outros tópicos.
A tipagem com RBS no Ruby pode parecer trabalhosa no começo, mas aos poucos ela nos ajuda a entender melhor o nosso próprio código, prevenir erros sutis e tornar o desenvolvimento mais seguro, especialmente em projetos grandes ou legados. Porém pode ser um desafio a curva inicial de aprendizado dos conceitos de tipagem.
Referências
Steep guide type checker: https://github.com/soutaro/steep/blob/master/guides/src/getting-started/getting-started.md#satisfying-the-type-checker-by-adding-a-guard
Steep anotations inline: https://github.com/soutaro/steep/blob/master/manual/annotations.md
Sorbet RBS comments support: https://sorbet.org/docs/rbs-support