Search code examples
rubyschemahashtablerbssteep

Ruby RBS: Declaring schema for a hash


In the application my team and I are working on, we heavily utilize Ruby hashes as data transfer objects(DTOs). Also, currently, we would like to try the combination of RBS and Steep to see if it will bring any noticeable benefit to the development process. Unfortunately, I can't seem to find a way how I could declare the strict schema for hash parameters. Let me show you an example:

Suppose we have the following Ruby code:

module Foo

  def some_func(args)
    x, y, z = args.values_at(:x, :y, :z)

    # do something with the params above
  end

end

So, I would like to create an *.rbs file that would describe the structure of the args hash above. Is this even possible?

Something similar is very easy to do in TypeScript, where hash tables are used very often as well:

type Args = {
  x: number,
  y: string,
  z: Array<number>
}

function some_func(args: Args): void;

Solution

  • Just wanted to add. The answer above is correct. However, you should not expect behavior similar to TypeScript.

    Unless I'm missing something evident (please correct me if I'm wrong), Steep will have a hard time "guessing" the exact shape of a hash.

    Consider the following examples:

    # class.rb: Illustrates usage of a simple class as a DTO
    
    module Examples
      module Dtos
        module Class
          class User
            attr_reader :id, :login, :roles
    
            def initialize(id:, login:, roles:)
              @id = id
              @login = login
              @roles = roles
            end
    
          end
    
          def self.run_example
            user = User.new(id: 1, login: "john.doe", roles: ["admin"])
    
            print_user(user)
          end
    
          def self.print_user(u)
            puts "User: id=#{u.id} login=#{u.login} roles=#{u.roles}"
          end
        end
      end
    end
    
    # class.rbs
    
    module Examples
      module Dtos
        module Class
          class User
            attr_reader id: Integer
            attr_reader login: String
            attr_reader roles: Array[String]
    
            def initialize: (id: Integer, login: String, roles: Array[String]) -> void
          end
    
          def self.run_example: () -> void
    
          def self.print_user: (User u) -> void
        end
      end
    end
    
    # hash.rb: Illustrates usage of a Ruby Hash as a DTO
    
    module Examples
      module Dtos
        module Hash
          def self.run_example
            user = { id: 2, login: "jenny.doe", roles: ["super_user"] }
    
            print_user(user)
          end
    
          def self.print_user(u)
            puts "User: id=#{u[:id]} login=#{u[:login]} roles=#{u[:roles]}"
          end
        end
      end
    end
    
    #hash.rbs
    
    module Examples
      module Dtos
        module Hash
          type user_dto = {
              id: Integer,
              login: String,
              roles: Array[String],
            }
    
          def self.run_example: () -> void
    
          def self.print_user: (user_dto u) -> void
        end
      end
    end
    

    And I also have main.rb where I run both examples:

    # main.rb: Entry point
    
    require_relative 'examples/dtos/class'
    require_relative 'examples/dtos/hash'
    
    
    def main
      Examples::Dtos::Class.run_example
      Examples::Dtos::Hash.run_example
    end
    
    main
    
    

    So, in the hash.rb example, Steep&RBS treats the user hash as Hash<Symbol, Integer | String | Array<String>> type, which means it is not able to infer the exact structure of a hash. Therefore, hash typing will not always give the desired effect. When I run the steep check command for both examples, the one that uses class as a DTO works perfectly fine. However, the file that utilizes a Hash fails the type check with the following error message:

    src/examples/dtos/hash.rb:7:19: [error] Cannot pass a value of type `::Hash[::Symbol, (::Integer | ::String | ::Array[::String])]` as an argument of type `::Examples::Dtos::Hash::user_dto`
    │   ::Hash[::Symbol, (::Integer | ::String | ::Array[::String])] <: ::Examples::Dtos::Hash::user_dto
    │     ::Hash[::Symbol, (::Integer | ::String | ::Array[::String])] <: { :id => ::Integer, :login => ::String, :roles => ::Array[::String] }
    │
    │ Diagnostic ID: Ruby::ArgumentTypeMismatch
    │
    └         print_user(user)
    

    Conclusion:

    If you're just starting a new project and you would like to use Steep to take advantage of type-checking in Ruby, you have to strongly consider utilizing simple classes as DTOs in your application.