Structural typing vs interfaces

I wrote a library tallboy to generate text based tables a while ago to learn Crystal.

Recently I rewrote it and Initially I was looking for golang like interfaces but in Crystal you have to explicitly include modules as interfaces.

Later I found that Crystal has structural typing and it does 90% of what I wanted but I am unsure the following.

# say I have an array of integers
arr = [1, 2, 3]
 
# and and class Group which holds an array of integers or strings
class Group
  def initialize(@items : Array(Int32 | String)
  end
end

# I can't just pass the array in
Group.new(arr)

# I have to first type cast each element
Group.new(arr.map(&.as(Int32 | String)))

Is using .as the best approach here and does anyone actually define modules as interfaces? I found using modules it’s cumbersome to cast back and forth.

and if you checked out the library any feedback at all I would love to hear it.

cheers
DT

1 Like

It depends a bit on the greater part of the code but two alternative venues may be either directly declaring the array of the right type, [1, 2, 3] of Int32|String or being more liberal in what your constructor accepts, @items : Array(Int32)|Array(String)|Array(Int32|String)

An alternative is doing the map inside Group’s constructor. Then client code doesn’t need to worry about mapping, they can just pass array of int, array of string, or array of both.

But I don’t see how this is related to modules and interfaces.

Didn’t occur to me that I could’ve made the constructor more liberal, thanks.

In my use case I have arrays that could contain any combination of 4 to 5 types (as a union).

alias Node = Text | Line | Joint | MergedNode`

Writing out all combinations manually is probably not feasible. Will macros help? I have zero experience with macros.

Thanks asterite for giving me another idea to try.

I really enjoy some the structural typing support because different objects just need to implement the same method and it just works. Whereas when trying to use modules as interfaces I found it too be cumbersome so I’m just wondering if that’s the right approach in Crystal.

1 Like

A good solution would probably to provide a .new overload which casts the array to the expected data type:

class Group
  def self.new(items)
    new(items.map(&.as(Node))
  end

  def initialize(@items : Array(Node))
  end
end

Another alternative could be to make Group generic and add a generic argument to define the type of @items. But I suppose that probably wont fit nicely with your usecase.

1 Like

Unfortunately the .new overload didn’t work in the following which should

alias ItemType = String | Int32 | Bool

class Group
  def self.new(arr)
    new(arr.map(&.as(ItemType)))
  end

  def initialize(@arr : Array(ItemType))
  end
end

Group.new([1, 2])

it’s pattern matching the initialize method instead of the .new with this error

 121 | def initialize(@arr : Array(ItemType))
                      ^---
Error: instance variable '@arr' of Group must be Array(Bool | Int32 | String), not Array(Int32)

I can get around it by using a different method name

class Group
  def self.from(arr)
    new(arr.map(&.as(ItemType)))
  end
end

potential bug?

Maybe a bug, but it’s actually a pending design decision around covariance and contravariance. The restriction Array(ItemType) will match an Array(Int32).

But there’s a simpler way:

alias ItemType = String | Int32 | Bool

class Group
  @array : Array(ItemType)

  def initialize(array : Array)
    @array = array.map(&.as(ItemType))
  end
end

Group.new([1, 2])
1 Like