A Big Stick

Ruby Enum

Standardize a few common things we want from Enums using a simple DSL

Enum

As projects I’m working on continue to scale, we continue to look for more ways to make boilerplate easy, mistakes hard, and standardize some common core patterns across teams.

A big one is how we deal with short lists of allowed values. In short, enums.

Look, I’m not saying I committed this bug to production, but maybe that’s exactly what I’m saying:

env_match = ENV["RAILS_ENV"] == "propduction"

Enums might also be a list of valid states in a state machine, such as PENDING, IN_PROGRESS, COMPLETE, and FAILED.

But unlike traditional enums and even Rails’ wonky implementation of them, I’m not interested in the converting consts to integers, like PENDING = 0, IN_PROGRESS = 1, and so on. The hit to immediate understanding of what a value of 2 when inspecting foo.state is is not worth the negligible space savings in practically all cases. No, I want the enum value COMPLETE to be displayed as "COMPLETE" in all contexts, must more like GraphQL’s enum where it is specified that

the client […] can operate entirely in terms of the string names of the enum values.

I also want this enum implementation to easily give me a list of all of the define values. Hence, simply defining a collection of consts will not do.

The duct tape

The bare minimum to accomplish the description above would be both a collection of consts and a list of the defined consts.

module STATES
  PENDING = "PENDING"
  IN_PROGRESS = "IN_PROGRESS"
  COMPLETE = "COMPLETE"
  FAILED = "FAILED"

  def self.values
    [PENDING, IN_PROGRESS, COMPLETE, FAILED]
  end
end

So this works great. We have STATES::IN_PROGRESS, we can read string values like "IN_PROGRESS", and we can use the values list to run validations like validates :state, inclusion: {in: STATES.values} as well as create pick lists for UI elements.

But let’s remove the boilerplate shared between all such instances.

Enumerating our requirements

I want the definition to look like this:

STATES = Enum.new do
  value "PENDING"
  value "IN_PROGRESS"
  value "COMPLETE"
  value "FAILED"
end

Then, for an enum represented by STATES:

  • You can specify a value as a named const such as STATES::IN_PROGRESS
  • The value each item in the enum is a readable string, such as STATES::IN_PROGRESS == "IN_PROGRESS"
  • The consumer defining an enum can specify non-default values, such as STATES::IN_PROGRESS == "in_progress"
  • You can easily get a list of all defined values, such as with STATES.values

Implement it

To implement the spec above, two things stick out:

  • Enum must inherit from Module, so that consts such as STATES::PENDING can be defined.
  • The block requires an interpreter to implement value.

Typically, I’ll implement the second requirement with an entirely separate class, such as EnumBuilder, using EnumBuilder.new.instance_eval(&blk). In this case, however, we’re just going to take a shortcut and define the value method right on Enum itself, and will call instance_eval on the block directly on self.

class Enum < Module
  attr_reader :values

  def initialize(&blk)
    @values = Set.new
    instance_eval(&blk) # <--- this needs to store the values from each `value` call
  end
end

Enum.new gives us a new instance of Module that has a values method as required. Since we’re instance_evaling the block on self, we now just need to implement value to define the const and store the value:

  def value(name, value = name)
    const_set(name, value)
    @values.add(value)
  end

And that’s it. But while we’re here, we’re going to add a little polish:

  • make value private so that it can’t be called later by other consumers
  • freeze @values so it can’t be modified later
  • make Enum enumerable so we can directly iterate over the values

Without further ado, the entire definition:

class Enum < Module
  include Enumerable

  attr_reader :values

  def initialize(&blk)
    super(&nil) # Don't pass the block to super
    @values = Set.new
    # Define an Enum by passing a block to `new` with calls to `value`
    instance_eval(&blk)
    # Once the values are defined, we don't want the set to be modified.
    @values.freeze
  end

  # To implement `Enumerable` methods, simply call `each` on `values`
  # In Rails, this could be replaced with `delegate :each, to: :values`
  def each(&blk)
    values.each(&blk)
  end

  private

  # Called during initialization from the block to add values to the Enum
  def value(name, value = name)
    const_set(name, value)
    @values.add value
  end
end

Now, with our example STATES enum exactly as written above:

STATES = Enum.new do
  value "PENDING"
  value "IN_PROGRESS"
  value "COMPLETE"
  value "FAILED"
end

we get:

STATES::PENDING
# => "PENDING"
STATES.values
# => #<Set: {"PENDING", "IN_PROGRESS", "COMPLETE", "FAILED"}>
STATES.include? "COMPLETE"
# => true
STATES.map(&:downcase)
# => ["pending", "in_progress", "complete", "failed"]

Or, if we prefer (or if dictated by legacy database value requirements), we can modify the string values

STATES = Enum.new do
  value "PENDING", "pending"
  value "IN_PROGRESS", "in progress"
  value "COMPLETE", "complete"
  value "FAILED", "failed"
end

to get:

STATES::PENDING
# => "pending"
STATES.values
# => #<Set: {"pending", "in progress", "complete", "failed"}>
STATES.include? "complete"
# => true
STATES.map(&:upcase)
# => ["PENDING", "IN PROGRESS", "COMPLETE", "FAILED"]

Personally, I find this much cleaner and easier to deal with. But hey, to each their own.