1

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;
TK-95
  • 1,141
  • 2
  • 10
  • 21
  • Nothing in your example code could be used to allow Ruby tools like TypeProf to infer a type. You can specify them as such in RBS, but you'd need a tool like Steep or Sorbet for type enforcement as Ruby itself doesn't do TPE-checking. Are you just asking how to specify the types you want in an .rbs file? – Todd A. Jacobs Jul 31 '23 at 02:32

2 Answers2

1

Yes, it is indeed possible to use RBS (Ruby Signature) to describe the structure of the hash parameters in your Ruby code. RBS is a static type checker for Ruby, and it can help improve the development process by providing type annotations and type checking.

In your example, you can create an RBS file to describe the structure of the args hash. Here's how you can do it:

# foo.rbs

module Foo
  # Declare the type for the args parameter in the `some_func` method
  type some_func_args = {
    x: Integer,
    y: String,
    z: Array[Integer]
  }

  # Declare the method signature for some_func
  def some_func(args : some_func_args) -> void
end
  1. Now, you need to integrate Steep into your project to take advantage of the RBS type checking. Follow the installation and setup instructions for Steep from the official documentation: https://github.com/soutaro/steep#installation

  2. Once you've set up Steep and added the foo.rbs file to your project, you can run the type checker to validate the types in your code:

steep check

Steep will use the type information from the foo.rbs file to check if the method calls match the declared types.

With this setup, you'll get static type checking for the some_func method's args parameter, similar to how TypeScript provides type checking for objects. It can help catch type-related issues early in the development process and improve the overall code quality and maintainability.

Nermin
  • 6,118
  • 13
  • 23
0

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.

TK-95
  • 1,141
  • 2
  • 10
  • 21