Possible to serializer/deserialize and object like Ruby marshaling?

I would like to relaunch the topic with a new approach I found to implement out-of-the-box serialization/deserialization.

Compared to my previous approach and the stdlib:

  • no monkey-patching for serialization, maybe for deserialization
  • out of the box serialization/deserialization for any type.
require "json"

module Crystalizer::JSON
  extend self

  def serialize(object)
    String.build do |str|
      serialize str, object
    end
  end

  def serialize(io : IO, object : O) forall O
    ::JSON.build(io) do |builder|
      serialize builder, object
    end
  end

  def serialize(builder : ::JSON::Builder, object : Int32)
    object.to_json builder
  end

  def serialize(builder : ::JSON::Builder, object : String)
    object.to_json builder
  end

  def serialize(builder : ::JSON::Builder, object : O) forall O
    builder.object do
      {% for ivar in O.instance_vars %}
        builder.field {{ivar.stringify}} do
          serialize builder, object.@{{ivar}}
        end
      {% end %}
    end
  end

  def deserialize(data : String | IO, *, to type : O.class) forall O
    deserialize ::JSON::PullParser.new(data), type
  end
  
  def deserialize(pull : ::JSON::PullParser, type : O.class) forall O
    {% begin %}
    {% properties = {} of Nil => Nil %}
    {% for ivar in O.instance_vars %}
      {% ann = ivar.annotation(::Serialization) %}
      {% unless ann && ann[:ignore] %}
        {%
          properties[ivar.id] = {
            type:        ivar.type,
            key:         ((ann && ann[:key]) || ivar).id.stringify,
            has_default: ivar.has_default_value?,
            default:     ivar.default_value,
            nilable:     ivar.type.nilable?,
            root:        ann && ann[:root],
            converter:   ann && ann[:converter],
            presence:    ann && ann[:presence],
          }
        %}
      {% end %}
    {% end %}

    {% for name, value in properties %}
      %var{name} = nil
      %found{name} = false
    {% end %}

    pull.read_begin_object
    while !pull.kind.end_object?
      key = pull.read_object_key
      case key
      {% for name, value in properties %}
      when {{value[:key]}}
        raise "duplicated key: #{key}" if %found{name}
        %found{name} = true
        %var{name} = deserialize pull, {{value[:type]}}
      {% end %}
      else raise "#{key} not found"
      end
    end
    
    O.new(
    {% for name, value in properties %}
      {{name}}: %var{name}.as({{value[:type]}}),
    {% end %}
    )
    {% end %}
  end

  def deserialize(pull : ::JSON::PullParser, type : String.class)
    pull.read_string
  end

  def deserialize(pull : ::JSON::PullParser, type : Int32.class)
    v = pull.int_value.to_i32
    pull.read_next
    v
  end
end


annotation Serialization
end

struct Point
  getter x : Int32
  @[Serialization(key: "YYY")]
  getter y : String

  def initialize(@x, @y)
  end
end


struct MainPoint
  getter p : Point

  def initialize(@p)
  end
end


data = %({"p": {"x": 1, "YYY": "abc"}})

point = Crystalizer::JSON.deserialize data, to: MainPoint
puts point # => MainPoint(@p=Point(@x=1, @y="abc"))
#{Crystalizer::YAML,
{Crystalizer::JSON}.each do |type|
  puts type.serialize point

  # ---
  # p:
  #   x: 1
  #   y: abc
  # {"p":{"x":1,"y":"abc"}}
end

This POC is of course the very base; it is not correct as is.

I would like to have others opinions on this. It be a shard at first.

One point I have not a golden solution: how to create an instance from an object T for deserialization?
T#new won’t necessarily take all ivars, and defining any custom method will required to monkey-patch either T or Object.

Another point, annotations: we should be able to tell how to serialize/deserialize without monkey-patching it with annotations. I can be done with named arguments, or an object passed as argument defining how to (de)serialize.