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;
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.