Portrait of Christopher

Christopher Bennell

I’m a full-stack web developer specializing in Ruby on Rails and Education Technology. Get in touch.

Parameterized Modules in Ruby

Reading the documentation for some dry-rb projects, like dry-auto_inject, I was intrigued by the syntax in examples like this:

1
2
3
4
class CreateUser
  include Import["users_repository"]
  ...
end

What is this usage of square brackets in the included module? Is this some arcane syntax I’ve never seen before? How can I replicate this in my own code?

It’s actually nothing too magical. Include isn’t a keyword, it’s a method (defined on Module itself) and it takes a Module as an argument.

We also know that we can define a method named [] (as class or instance methods) to give that object “array access” syntax.

1
2
3
4
5
6
7
8
9
class List
  def [](index)
    items = [:zero, :one, :two]
    items[index]
  end
end

list = List.new
puts list[1] # :one

Same for class methods.

1
2
3
4
5
6
7
8
class List
  def self.[](index)
    items = [:zero, :one, :two]
    items[index]
  end
end

puts List[2] # :two

If this [] method returns a module, we can supply that return value to our include call.

Let’s implement the [] method on our module.

1
2
3
4
5
6
7
8
9
10
module ParameterizedModule
  def self.[](item)
    puts "It works"
    self
  end
end

class Test
  include ParameterizedModule["test"]
end

We define [] on the module itself so we can call it as we would a class method. We return self because include is expecting a Module.

Now comes the tricky part. How can we use the supplied parameter to customize the behaviour of the module? The parameter is only in scope in the call to self.[], not the rest of module implementation.

The trick is to use define_method to add an instance method to the module. define_method captures the lexical scope, so the parameters are available within the method you’re defining.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module ParameterizedModule
  def self.[](item)
    define_method :item_parameter do
      item
    end

    self
  end

  def get_item
    item_parameter
  end
end

class Test
  include ParameterizedModule["test"]
end

puts Test.new.get_item # "test"

This approach does add an extra method (item_parameter) to the client class, which is not ideal, as we’re slightly polluting that class’ namespace; I prefer to keep the module’s interface as small as possible. But this is an OK trade-off in my opinion. Just make sure to name your method appropriately to avoid a collision.

We could also choose to just dynamically define the methods that reference the parameter themselves.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module ParameterizedModule
  def self.[](item)
    define_method :get_item do
      item
    end

    self
  end
end

class Test
  include ParameterizedModule["test"]
end

puts Test.new.get_item # test

But this comes with its own problem. I can imagine myself doing a global search for “def get_item” to find where this method is defined; this approach breaks that ability. I prefer to have my important methods look like methods. The item_parameter method in the previous example is more like plumbing, so we can get away with it there.

“Array Access” vs. Regular Method Calls

Using the array access syntax implies that we are accessing an item from a collection. That is the convention associated with the syntax, and this is what’s happening in the example from dry-auto_inject. If we want to keep the semantics of a regular method call wherein we are supplying a parameter that doesn’t imply an item in a list, we can just rename the method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module ParameterizedModule
  def self.with_item(item)
    define_method :get_item do
      item
    end

    self
  end
end

class Test
  include ParameterizedModule.with_item("test")
end

puts Test.new.get_item

This makes it a bit more obvious what we are actually doing.

There are other situations in the dry-rb documentation where it seems to just use the array access as a shortcut for a “default” method, to avoid needing to call a specific method on a class. From dry-types:

1
2
Types::Strict::String["foo"]
# => "foo"

There are a few similar example in the official Ruby docs for Data.

1
2
3
4
5
6
7
8
9
10
11
12
13
# https://docs.ruby-lang.org/en/master/Data.html
Measure = Data.define(:amount, :unit)

# Positional arguments constructor is provided
distance = Measure.new(100, 'km')
#=> #<data Measure amount=100, unit="km">

# Alternative form to construct an object:
speed = Measure[10, 'mPh']
#=> #<data Measure amount=10, unit="mPh">

# https://ruby-doc.org/stdlib-3.0.2/libdoc/set/rdoc/Set.html#class-Set-label-Example
s1 = Set[1, 2] #=> #<Set: {1, 2}>

It seems to have become a convention for calling a default method on a class, much like .call. I want to like this approach, because it’s clean and concise, but I think it’s abusing the semantics of the array access syntax.

Use Case

One way I’m planning to use this in my own code is to supply a list of plugins when including my Authentication module in my Rails ApplicationController (I may expand on this in a future post).

1
2
3
4
5
6
7
8
class ApplicationController < ActionController::Base
  include Authentication[
    Authentication::LTIPlugin.new,
    Authentication::SessionPlugin.new,
    Authentication::XhrPlugin.new
  ]
  before_action :require_login
  ...

Conclusion

Parameterized modules give us new options for expressing ourselves in Ruby. The syntax may seem unconventional at first glance, but the underlying mechanisms are straightforward.

The main trade-off in these approaches is between dynamically defining an important method (which becomes less searchable) and dynamically defining an extra helper method, which pollutes the client namespace.

I’m interested in exploring this further and discovering other approaches to solve the problems mentioned above.