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

0 Comentário