Hash declaration: What am I doing wrong here?

Imagine I have the following code in ruby:

module Foo
  class Client
   def initialize(uri, options = {})
      @uri = URI.parse(uri)
      @base_uri = "#{@uri.scheme}://#{@uri.host}:#{@uri.port}"
      @user = options[:user]
      @password = options[:password]
      @http_options = options[:http_options] || {}
      @http_options[:open_timeout] ||= options[:open_timeout] || 2
      @http_options[:read_timeout] ||= options[:read_timeout] || 5
    end
end
end

I’m trying to rewrite it in Crystal, however I cannot get the hash data types right…

what I have now:

module Foo
class Client
    @user : String | Nil | Symbol
    @password : String | Nil | Symbol
    @http_options: NamedTuple(open_timeout: Int32, read_timeout: Int32) | Nil
    # @http_options: Hash(Symbol, Int32) | Nil

    def initialize(uri : String, **options)
       @uri = URI.parse(uri)
       @base_uri = "#{@uri.scheme}://#{@uri.host}:#{@uri.port}"
       @user = options.fetch(:user, nil)
      
       @password = options.fetch(:password, nil)

       if options.has_key?(:http_options)
        @http_options = options.fetch(:http_options, {open_timeout: 2, read_timeout: 5}) 
        # cannot change it, should i try something like hash#merge?
        # @http_options.fetch(:open_timeout, 2) 
        # @http_options.fetch(:read_timeout, 5)
      end
    end
end
end

My questions are

a) what is the crystal way to write similar code?
b) any example of similar code?

Is there any reason to use this **options or options namedtuple/hash at all? I’d say it would be more idiomatic to just have your constructor define all these properties as their own parameters/ivars and call it a day. Something like:

module Foo
  class Client
    @uri : URI
    @base_uri : String
    @password : String?
    @user : String?
    @open_timeout : Int32
    @read_timeout : Int32

    def initialize(
      uri : String,
      @user : String? = nil,
      @password : String? = nil,
      @open_timeout : Int32 = 2,
      @read_timeout : Int32 = 5
    )
      @uri = URI.parse(uri)
      @base_uri = "#{@uri.scheme}://#{@uri.host}:#{@uri.port}"
    end
  end
end

Could also argue you dont really need base_uri, user, or password as you can just get all that off the uri. Might also be a good idea to allow passing a Uri instance instead of a string. Supporting both would be helpful I’d say.

1 Like

hey thank you for your answer. Yes, I’m trying to learn the idiomatic way of writing crystal. In Ruby we normally have the “optional options” being passed in a hash, having an empty hash as default, to avoid having to deal with nils… Because I’m trying to port a ruby code, I’m confronting a ruby code and:

a) trying to translate it “compilable” crystal code
b) trying to make it crystal idiomatic
c) avoiding to change the API as much as possible

My dream would be be able to keep the same documentation and integration test for sake of backward compatibility and quality assurance, if possible.

1 Like

My 2 cents on this is basically while Ruby is syntactically similar to Crystal, they are ultimately different languages. You definitely can port things 1:1, but IMO that is usually not the best option since you’re treating Crystal as just compiled Ruby and inheriting all of Ruby’s issues/patterns when there may be better ways to go about it.

Like to me your three goals are somewhat mutually exclusive. Like can you really make everything idiomatic Crystal without changing the API by just porting things from Ruby 1:1? Maybe, but might be better to take a step back and try and figure out what problem that Ruby code is solving and then implementing something in Crystal to solve that problem, using the tools and features of Crystal to do so.

E.g. Crystal has overloads so you could prob make a def self.new(uri : String, options : Hash): self that consumes your hash, and forwards them to the new/better API. Then you get the best of both worlds.

2 Likes

From my point of view, “optional options” is the worst things ever in Ruby/Rails which caused by the bad design of named arguments(@matz regrets this always, although, partly fixed on Ruby 3).

1 Like

yes, it was “fixed” with keyword arguments, which is already present in ruby 2… but not every code that we write or maintain is able to be changed as we want…

Yes, the overload is a killer feature, I used to hate it from other languages, but in such scenarios it makes things much simpler…

In fact, some breaking changes which make use named arguments more clear introduced since 2.7, not ruby 2.0

https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/