Issue initializing instance variables using a macro on `#initialize`

Hi! I wanted to used a macro to streamline the initialization of a struct based on the values of another class with which it shares many instance variable names. For instance:

class User
  property id = 1
  property name = "Name"
end

struct SerializedUser
  getter id : Int64
  getter name : String

  def initialize(user : User)
    @id = user.id
    @name = user.name    
  end
end

In this case all the variable names are short, but for longer ones it’s repetitive. I would like to use a macro to do something like this in SerializedUser:

def initialize(user : User)
  set :id
  set :name
end

This is the macro I’m using:

macro set(var)
  @{{var.id}} = {{@def.args.first.name}}.{{var.id}}
end

The strange thing is that it seems to work, i.e., the macro expands correctly and the variable is initialized, but the compiler complains with “Error: instance variable ‘@id’ of SerializedUser was not initialized directly in all of the ‘initialize’ methods, rendering it nilable”.

For instance, given a user with id = 1, in the following method the macro works and @id is correctly initialized, but if I don’t put a explicit initialization, e.g. (@id = 0), or similar it won’t even compile.

def initialize(user : User)
  set :id
  puts @id # => 1
  @id = 0
  @name = name.id
end

Perhaps I’m missing something or my approach is not possible (nor advisable) for #initialize?

Thanks in advance.

I think it has something to do with how/when the macros are expanded. I think you could do better tho. I’m also a fan of using dedicated DTO types to avoid coupling core entities with serialization related things and have thought about this a bit in the past and came up with:

class User
  property id = 1
  property name = "Name"
  property active = true
end

module DTO(T)
  macro included
    macro finished
      {% verbatim do %}
        {% verbatim do %}
          def initialize(obj : T)
            {% for ivar in @type.instance_vars %}
              @{{ivar.name.id}} = obj.{{ivar.name.id}}
            {% end %}
          end
        {% end %}
      {% end %}
    end
  end
end

record SerializedUser, id : Int32, name : String do
  include DTO(User)
end

pp SerializedUser.new User.new # => SerializedUser(@id=1, @name="Name")

Where you can use the record macro to control what properties are included, then just include the DTO module with the type it wraps, and it’ll generate the entire constructor itself. Could probably get more advanced by using annotations to control mapping of properties, error handling to ensure T actually has properties you’re defining via the record, etc. But I think this works pretty well nonetheless for something fairly simple.

2 Likes

That was quick! I adapted your solution by adding a DTO::Ignore annotation and an optional hook to initialize instance variables not copied from obj. Thanks!

1 Like