Functional Options in Ruby

In this article, I would like to suggest the use of a very common pattern in Go, Functional Options, but adapted to the Ruby language.

As an example, you can see it used in OpenTelemetry Go

The problem

Here is a slightly modified example of a method definition from a Ruby component owned by my team:

def initialize(component:, environment:, version: nil, instance: nil, io: nil, vendorA_api_key:, vendorB_api_key:)

The number of attributes is quite high, and is meant to keep growing, as we could want to support more vendors
Most of the time, components using this library will use only a portion of those arguments, not all of them.

We want to keep this method readable. So, how can we reduce the number of arguments?

Hash Options

We could replace the method with a configuration hash, so we have a single argument.

def initialize(options = {})

This way of handling options has its advantages. We now end up with a single argument, and we can extend the options as much as we need to.

However, it has a few drawbacks as well. Specifically, defaults will be harder to handle. We will need to handle the case of an unset option when we need to use it, as opposed to as close to where the option is defined.

Ruby passes all its variables by value. So we don’t have the issue of changing the content of our options hash after it has been passed to the initializer. However, that’s something which could be quite confusing to read:

options = { api_key: "abcd" }
MyLibrary.new(options)
options[:api_key] = "def" # What happens here?

Functional Options

What if we could do the following:

MyLibrary.new(MyLibrary.with_api_key("abcd"), MyLibrary.with_environment("development"))

I am passing all my options in a quite explicit way, there’s no outstanding variable which can later be modified, and I can definie the behavior of that option as soon as it’s being called, not later.

An implementation

Here is an implementation proposal:

require "ostruct"

# Usage: MyLibrary.new(MyLibrary.with_api_key("abcd"))
class MyLibrary
	def self.with_api_key(key)
		lambda do |klass|
			vendor = OpenStruct.new(key: key) # In a real world, we could use any logic to set the vendor class
			klass.send(:add_vendor, vendor)
		end
	end

	def initialize(*opts)
		@vendors = []

		opts.each do |opt|
			opt.call(self)
		end
	end

	private

	def add_vendor(v)
		@vendors << v
	end
end

What happens here?

First, we define one option method, with_api_key. That method returns a lambda, where the attribute is the class needing to be configured.

While we are outside the context of the method, and therefore cannot call private methods, I am taking the liberty of using call to do so anyway, as we are technically within the same namespace.

In initialize, we accept a single argument: an infinite number of options, each of which needs to be a lambda accepting the class as its argument.
We then loop through all those lambdas and apply them to our class, which will run the appropriate configuration.

Conclusion

I am finding this pattern to be very elegant in Go, as well as in Ruby. It allows specifying a large number of very options to any class, and permits setting the logic for those options right where they are defined (in the with_api_key method) rather than in separate places of the method instanciation.