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 commited 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 initailize(&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.