How to allow unions to be less strict inside classes?

test = Hash(String, Int32 | String).new


test["hello"] = 1
test["hello"] = "test"

pp test


class Test
  property modifications = Hash(String, Int32 | String).new
  
  def initialize(@modifications)
  end
end


t = Test.new( {"hello" => 1} )
t = Test.new( {"hello" => "a string!"} )

pp t 

Error:

Error: instance variable ‘@modifications’ of Test must be Hash(String, Int32 | String), not Hash(String, Int32)

test works as expected, however, if that same kind of Hash is used inside a class, it will raise an exception.

I think the difference is this: in top-level you create a hash and then assign values to keys. In the class however you set a default hash, but then instantiate the class with a different type.

I would suggest typing the vars explicitely, it really helps with debug later. It kinda defeats the purpose of inferrance, but in my experience better safe than sorry.

It’s not the same kind of hash. The hash type, if not given an explicit type, is inferred from its members. If you only have string values it will be a Hash with String as a key type.

To solve your issue use a hash literal like {…} of String => String | Int32

For this specific use case you can use the array-like initialization. It could help to scope the boilerplate of dealing with a union.

class Test
  getter modifications = {} of String => String | Int32
  delegate :[]=, to: modifications
end

c = Test{"hello" => 1, "bye" => "a string"}
c.modifications # => { "hello" => 1, "bye" => "a string" } : String => String | Int32
4 Likes

This is similar to what I was thinking. If the actual code is as simple as the example code, this is probably the best approach.

If the actual code is more complicated, you could use a type alias for the hash type you actually want. The neo4j shard defines a Map type which is just mapping string keys to Neo4j-serializable values, defined as a Hash of those types. So when I need to pass one to a query, instead of:

connection.execute(query, { "foo" => "bar" }) # pass "bar" as the `foo` query param

I prefix the hash with the Map type:

connection.execute(query, Neo4j::Map { "foo" => "bar" })

Applying that to the example code, it would look something like:

class Test
  alias Modifications = Hash(String, Int32 | String)
  property modifications = Modifications.new
  
  def initialize(@modifications : Modifications)
  end
end

pp Test.new(Test::Modifications {"hello" => "a string!"})

Alternatively, if you wanted to implicitly coerce the hash, initialize could be defined as:

def initialize(modifications)
  @modifications = modifications.as(Modifications)
end

… so that the call site could pass in a plain hash that conforms to those types, but it’d be worth looking at how it would be called throughout the app to make that judgement, I think.

1 Like

Am I treading in bad water with this? I have a feeling I should be explicit with my types now that I think of it, and am glad this error is caught.