Strange compiler error with json serializable type family

Folks, I ran into a strange compilation error I am unable to solve :frowning_face:

The following code attempts to isolate it:

require "json"

class Base
  include JSON::Serializable
  property name : String = "default"
end

class Extended < Base
end

map = {
  "key1" => Base,
  # "key2" => Extended,
}

puts map.class.inspect
puts map["key1"].new(JSON::PullParser.new("{}")).inspect
# puts map["key2"].new(JSON::PullParser.new("{}")).inspect

It fails with:

In /usr/local/Cellar/crystal/1.12.1_1/share/crystal/src/json/serialization.cr:181:9

 181 | def initialize(*, __pull_for_json_serializable pull : ::JSON::PullParser)
           ^---------
Error: instance variable '@name' of Base+ was not initialized in this 'initialize', rendering it nilable

but it compiles if you either

  • make @name nilable (which is not what I want) or
  • comment out the sublass (Extended) definition (what I dont want either since the actual code has a whole bunch of subclasses here)

Any help or hint would be very much appreciated!

I think there’s a few things going on:

  1. The type of the value returned from map is Base+
  2. Base is not abstract
  3. include JSON::Serializable removes the default argless constructor

Changing any of these things will resolve your issue. I guess the tl;dr is the compiler just can’t prove that @name will be initialized in this context with how you have things setup.

  1. Don’t use a hash like this
  2. Make Base abstract
  3. Just define an actual constructor within Base. E.g. def initialize(@name : String); end
1 Like

I’ve seen that. But what does it mean?

The failing constructor is the one created by the macro in JSON::Serializable which processes the instance variables provided via the @type.

Could it be that in the context of Base+ (whatever that means) the macro doesnt see the right instance variables?!

https://crystal-lang.org/reference/1.13/syntax_and_semantics/virtual_and_abstract_types.html

Yea I’m just not sure if this is a bug, or is working as designed. Maybe some others will have more concrete thoughts.

1 Like

I can’t imagine this is working as intended, tbh. The error message says instance variable '@name' of Base+ was not initialized in this 'initialize', rendering it nilable, but that isn’t true. That ivar isn’t nilable regardless of whether it’s initialized to a non-nil value in that constructor or any other in any descendant class because it has a default non-nil value in the base class.

If you omit the hash it works just fine:

require "json"

class Base
  include JSON::Serializable

  property name : String = "default"
end

class Extended < Base
end

p! Base.from_json("{}")
p! Extended.from_json("{}")

Using a hash like this is pretty reasonable, too. I use them as type maps in a lot of my own libraries.

The main difference is that I use an abstract struct base, but that shouldn’t matter here because there’s nothing that sets @name to a nilable value.

By “working as intended” I meant that there could be a state in which this error is valid given the current rules of the compiler. Feels like sometime @HertzDevil would know. To us it doesn’t seem that way, but :person_shrugging:.

I think it does matter because having structs requires all the parent types to be abstract, thus entirely preventing this scenario where the typing is reduced to Base+ vs Base.class.

1 Like

Thank you so far guys!

I tried to investigate further on this, but all Ive seen is even more irritating … It appears to me that all the magic that makes crystal so elegant is really complicated voodoo stuff!

I can’t imagine, that what I am trying to do hasn’t been done before: pick the right type for deserializing some json.

Does anyone have the one enlightening hint? Please!

Can you share some more context around your use case? Like does the JSON data itself have a discriminator key? Or are you determining it outside of the data itself? or?

sure:
Its an attempt to refactor an existing app which was written in python and js. My json backed types are issues which indicate some kind of problem in a monitoring system. An issue is alwasy tied to an asset and has a timestamp of first appearance and a timestamp of last confirmation. Beside that every type of issue is different (carries different information). So there is this type structure Issue base class and lots of derived concrete issue classes. The issues are backed by an sql table with json data for the various subtypes. There are various agents across the network which monitor/collect certain issues types.

Here is a sample of how I think issues should look like in crystal:

class DiskMissing < Issue
  kind("hw_storage_disk_missing")
  
  key(
    slot : UInt32
  )

  tabelize do
    column "Slot" do |row|
      text row.key.slot.to_s
    end
  end
end

class LampEOL < Issue
  kind("hw_projector_lamp_eol")

  detail(
    hours : UInt32,
    hours_remain : Int32,
    model : String,
    serial : String,
  )

  tabelize do
    column "Lifetime" do |row|
      percent = row.detail.hours * 100.0 / row.details.hours_max
      div {
        div(style: "width: 100%;") {
          div(style: "width:#{percent}%;") {
            text "#{percent.to_i}%"
          }
        }
      }
    end
  end
end

There is some macro magic involved:

  • the type registration (mapping db name to crystal class is done via the kind marco by adding this class to this Hash (our troublemaker) inside issue base class
  • there is a difference of key attributes and just ordinary attributes which is modeled with the key and detail macros.
  • an issue knows how to display itself …

To add new issue types to the system, a programmer shall just create a new file with such an issue class inside and done. (of course someone has to also implement the agent collecting this new issue).

The base class has a class method load which returns an array of all current issues (fetches the db table and serializes each row into the correct issue sub class or drops it if no suitable class registered).

Hope this was not too much text …

Okay, so it seems to make sense that Issue should be abstract? This should be enough to resolve your issue. Otherwise if the JSON itself has a unique key when deserializing it you could look into JSON::Serializable - Crystal 1.13.1 as well.

And this hash is stored as a class var or something? How is it used? If it’s only used as part of the .load method to know all the issue types that need to be loaded, you could probably replace that with an annotation and {% for issue in Issue.subclasses %} macro to iterate over all the issues types, then access the table name off the annotation or give a compile time error if it’s missing.

1 Like

I like the idea with iterating subclasses and use annotations!
Thank you very much!

But after all do I think, that the observed behaviour is a bug:
The JSON::Serializable subclass used as the class itself works fine. But when instantiated in a Type+.class context, it fails.
I believe the reason is that in the Type+.class context the macro generated constructor code doesnt see any instance_vars at all.
Unfortunately I lack the competence to dig deeper there. But maybe someone should have a look at it…

1 Like

Reduced:

class Base
  property name : String = "default"

  def initialize(value : ::Int32)
    {% @type %}

    if true
      @name = value.to_s
    end
  end
end

class Extended < Base
end

pp (Base || Extended).new(10)
In test.cr:4:7

 4 | def initialize(value : ::Int32)
         ^---------
Error: instance variable '@name' of Base+ was not initialized in this 'initialize', rendering it nilable

Seems somewhat related to Initialization through macros "doesn't count" · Issue #2731 · crystal-lang/crystal · GitHub. Given it only reproduces when doing the {% @type %} hack? :thinking:.

1 Like

I also noticed with:

abstract class Base
  property name : String = "default"

  def initialize(value : ::Int32)
    @name = value.to_s
  end
end

class Extended < Base
end

pp (Base || Extended).new(10)

You get a runtime compiler error? :thinking:

Unhandled exception: Can't instantiate abstract class Base (Exception)
  from /home/george/.cache/crystal/crystal-run-test.tmp in '??'
  from test.cr:4:3 in 'new'
  from test.cr:12:4 in '__crystal_main'
  from /home/george/dev/git/crystal/src/crystal/main.cr:118:5 in 'main_user_code'
  from /home/george/dev/git/crystal/src/crystal/main.cr:104:7 in 'main'
  from /home/george/dev/git/crystal/src/crystal/main.cr:130:3 in 'main'
  from /usr/lib/libc.so.6 in '??'
  from /usr/lib/libc.so.6 in '__libc_start_main'
  from /home/george/.cache/crystal/crystal-run-test.tmp in '_start'
  from ???
1 Like