`JSON::Serializable` w/ Inheritance

I’m playing around with implementing ActivityPub where most of the entities over the JSON API inherit from other types. For example, Create < Activity < Object. And that works as long as I don’t define an initialize method that lets me instantiate them myself to be able to send the instances to other servers. This snippet illustrates what I’m trying to do:

require "json"

abstract class A
  include JSON::Serializable

  getter a1 : String?
  getter a2 : String?

  def initialize(*, @a1 = nil, @a2 = nil)
  end
end

class B < A
  getter b1 : String? = nil

  def initialize(*, @b1 = nil, **kwargs)
    super(**kwargs)
  end
end

pp B.new(
  a1: "a1",
  b1: "b1",
)

pp B.from_json({
  a1: "a1",
  b1: "b1",
}.to_json)

This represents some superclass A that has a bunch of properties common to all ActivityPub objects, and then subclasses add extra ones. But it doesn’t compile:

Showing last frame. Use --error-trace for full trace.

In examples/stuff.cr:17:5

 17 | super(**kwargs)
      ^----
Error: no parameter named '__pull_for_json_serializable'

Matches are:
 - A#initialize(*, a1 = nil, a2 = nil)

shell returned 1

Are there some semantics I’m overlooking? Can anyone think of any workarounds?

Just remove the * from your initializers and it’ll work. Alternatively, duplicate the params on each constructor/super call to avoid Crystal thinking you’re calling the JSON::Serializable overload.

Unfortunately, this does not work. I still got the same error from this code:

require "json"

abstract class A
  include JSON::Serializable

  getter a1 : String?
  getter a2 : String?

  def initialize(@a1 = nil, @a2 = nil)
  end
end

class B < A
  getter b1 : String? = nil

  def initialize(@b1 = nil, **kwargs)
    super(**kwargs)
  end
end

pp B.new(
  a1: "a1",
  b1: "b1",
)

pp B.from_json({
  a1: "a1",
  b1: "b1",
}.to_json)

This does work, however, which is a bit surprising. I feel like since kwargs is known at compile time to be a NamedTuple with known keys, double-splatting it would know to call the @a1 = nil, @a2 = nil overload.

Something I just thought about while writing the above paragraph, though, is that in that overload, every argument is optional, and I wondered if that made a difference. So I experimented with removing some of the default nil values. It compiles as long as @b1 is not an optional argument. Does this sound like a bug or is it unreasonable to expect the type checker to figure this out?

Hmm, are you sure? Carcin Seems to work fine no?

Oooh, I see, it’s not super obvious from the code snippet in the post but there’s more code if you scroll. Once you add the .from_json call, it stops compiling.

Ahh okay, yea that does it. I think what’s ultimately happening is you’re redefining the initializer defined by JSON::Serializable, which .from_json ends up calling. Tho I’m not sure if this is expected or a bug given Crystal in other cases is able to figure it out: Carcin. So reducing the code to figure out what the trigger is might be helpful.

EDIT: By reduce I mean try and figure out what exact combination of constructors/inheritance causes it, or if its strictly related to the included module/inherited hook.

Ahh, that sounds right. I seem to remember something built into JSON::Serializable to handle that scenario (a redefinition of the initialize method inside an inherited macro, I think?), but looking through the file history I don’t see it, so I must’ve imagined it.

Wait, the thing I was apparently imagining actually works, though :exploding_head: Only difference here is the redefinition of initialize for the JSON::PullParser:

require "json"

abstract class A
  include JSON::Serializable

  getter a1 : String?
  getter a2 : String?

  def initialize(*, __pull_for_json_serializable pull : ::JSON::PullParser)
    super
  end
  macro inherited
    def initialize(*, __pull_for_json_serializable pull : ::JSON::PullParser)
      super
    end
  end

  def initialize(*, @a1 = nil, @a2 = nil)
  end
end

class B < A
  getter b1 : String? = nil

  def initialize(*, @b1 = nil, **kwargs)
    super(**kwargs)
  end
end

pp B.new(
  a1: "a1",
  b1: "b1",
)

pp B.from_json({
  a1: "a1",
  b1: "b1",
}.to_json)

carcin

FYI, I commented out the macro inherited .. end part from that Carcin, and it works:

https://play.crystal-lang.org/#/r/d56l

1 Like

Nice! This class hierarchy has a few layers so that might be something that needs to stay, but I’m not sure.