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 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 fromModule
, so that consts such asSTATES::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_eval
ing 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.