Dynamic union type as a constraint to class method argument type

I want to define a dynamic union type HobbyType as a constraint to the class method printype argument.

annotation Hobby
end

struct Pizza
end

struct Sport
end

struct Movie
end

class Person
  alias HobbyType = String | {{ hobby_types }}

  @[Hobby]
  @favorite : Movie

  @[Hobby]
  @eating : Pizza

  @name : String

  macro hobby_types
    {{ "Union(#{@type.instance_vars.select(&.annotation(Hobby))
        .map(&.type.id).splat})".id }}
  end

  def self.printype(hobby : HobbyType)
    pp! "#{hobby}"
  end
end

But the code doesn’t compile and produce this error.

 14 | alias HobbyType = String | {{ hobby_types }}
                                 ^
Error: unexpected token: "{{"

How to fix this and make it work?

What you’re trying to do here isn’t directly possible since instance vars are only known within the context of a method, which you can’t define an alias within. You could use some sort of macro to declare the ivars and push their types to some array you use within a macro finished hook to build out the alias if you really wanted tho. Otherwise not sure there’s much you can do.

As long as it can achieve the goal described on the subject of this topic, alternative solution to alias is OK. I’m trying to move this forward but got another error.

annotation Hobby
end

struct Pizza
end

struct Sport
end

struct Movie
end

class Person
  #alias HobbyType = String | {{ hobby_types }}

  macro add_hobby(hb)
    @[Hobby]
    @{{ hb.var }} : {{ hb.type }}?
  end

  @[Hobby]
  @favorite : Movie?

  @[Hobby]
  @eating : Pizza?

  @name : String?

  macro union_hobby_types
    {{ "Union(#{@type.instance_vars.select(&.annotation(Hobby))
        .map(&.type.id).splat})".id }}
  end

  def self.printype(hobby : HobbyType)
    pp! "#{hobby}"
  end

  def hobby_types
    {{ union_hobby_types }}
  end
end

pp Person.new.hobby_types

and the error:

 39 | {{ union_hobby_types }}
         ^----------------
Error: undefined macro variable 'union_hobby_types'

The macro should be visible within the class. But why undefined?

Macro defs are called like regular methods. They are not visible in the macro language (inside the curly braces). Just drop the curly braces and it works.

See Macro methods - Crystal

annotation Hobby
end

struct Pizza
end

struct Sport
end

struct Movie
end

class Person
  #alias HobbyType = String | new.hobby_types

  macro hobby(hb)
    @[Hobby]
    @{{ hb.var }} : {{ hb.type }}?
  end

  hobby favorite : Movie
  hobby eating : Pizza

  @name : String?

  macro union_hobby_types
    {{ "Union(#{@type.instance_vars.select(&.annotation(Hobby))
         .map(&.type.id).splat})".id }}
  end

  macro finished
    pp! new.hobby_types # => (Movie | Pizza | Nil)
    def self.printype(hobby : new.hobby_types)
      hobby.class
    end
  end

  def hobby_types
    union_hobby_types
  end
end
pp Person.new.hobby_types
pp Person.printype(Movie.new)

It seems it is almost there but another error,

 > 1 | pp! new.hobby_types # => (Movie | Pizza | Nil)
 > 2 | def self.printype(hobby : new.hobby_types)
                                 ^
Error: unexpected token: "new"

I expect printype arg hobby is declared as the result of hobby_types(). How to fix this?

Hi! Could you give.an overview of what you are trying to do or solve (and not how to implement the solution you have in mind)?

I’m trying to solve a problem in Granite.
If we have a model defined as described in the doc,

enum OrderStatus
  Active
  Expired
  Completed
end

class Order < Granite::Base
  connection mysql
  table foos

  # Other fields
  column status : OrderStatus, converter: Granite::Converters::Enum(OrderStatus, String) 
end

It will fail when running the code like Order.create(price: 2, product_id: 9, status: OrderStatus::Active) or order.update(status: OrderStatus::Expired), because the implementation of create or update restricts the argument type(ModelArgs) to a list of basic types commonly known to a db. It doesn’t consider the user customized type OrderStatus.

So my code here is just a simplified version showing the issue and my attempt to fix it.

Something like tthis?

module Model
  macro included
    HOBBIES = [] of Nil

    macro finished
      alias HobbyType =
        \{% for hb in HOBBIES %}
          \{{ hb.type }} |
        \{% end %}
        String
    end
  end
end

struct Pizza
end

struct Sport
end

struct Movie
end

class Person
  include Model

  macro hobby(hb)
    {% HOBBIES << hb %}
    @{{ hb.var }} : {{ hb.type }}?
  end

  hobby favorite : Movie
  hobby eating : Pizza

  @name : String?

  macro finished
    def self.printype(hobby : HobbyType)
      hobby.class
    end
  end

  def hobby_types
    HobbyType
  end
end

pp Person.new.hobby_types
pp Person.printype(Movie.new)

That solution might work, but it’s pretty fragile. For example, HobbyType isn’t defined until the finished hooks execute. That’s a pretty strong restriction. I don’t think this is really a good idea.

I’m not familiar with the details of Granite, but I’d try to solve this by enhancing the .create/#update methods to allow types that can be converted.

The implementation of .create is this:

    def create(**args)
      create(args.to_h)
    end

I suppose it should be possible to iterate over args and apply defined converters on column values that need to be converted.

Fragile how? What do you need the type before the finished hook? It’s the same as defining the type at the end of the program.

That said, I would always try to avoid macros or. This kind of stuff. But I guess it’s inevitable given the features the language has.

Awesome! This works as I expected.

After HobbyType is defined, HOBBIES is actually not needed. HOBBIES.compact! can’t remove any elements. HOBBIES=nil also doesn’t work. So HOBBIES[] is still available during runtime as

Person::HOBBIES # => [nil, nil]

It it possible to use a temorary variable that is only used during compile-time so that it will not consume any runtime memory?

If you never use HOBBIES in runtime code it won’t be initialized, and following that there will not be any code generated.
So your code to print Person::HOBBIES only causes it to be compiled. If you’d omit that, it wouldn’t.
You can make sure to exclude it from code generation by changing the type to Array(_): [] of _ - this will never compile, but it works for macro use.

1 Like