How to Implement an Enumerated Type in Rails

Chris Jones, Development Director

Article Category: #Code

Posted on

Have you ever wanted to use an enumerated type in your Rails app? After years of feature requests, Rails 4.1 finally added them: a simple implementation that maps strings to integers. But what if you need something different?

On a recent project, I implemented a survey, where animals are matched by answering a series of multiple-choice questions. The models looked like this:

 class Animal < ActiveRecord::Base
 has_many :answer_keys
end

class AnswerKey < ActiveRecord::Base
 belongs_to :animal

 validates :color, :hair_length, presence: true
end

An animal has many answer keys, where an answer key is a set of survey answers that matches that animal. color and hair_length each represent a multiple-choice answer and are natural candidates for an enum.

The simplest possible implementation might look like this:

 validates :color, inclusion: { in: %w(black brown gray orange yellow white) }
validates :hair_length, inclusion: { in: %w(less_than_1_inch 1_to_3_inches longer_than_3_inches) }

However, there were additional requirements for each of these enums:

  • Convert the value to a human readable name, for display in the admin interface
  • Export all of the values and their human names to JSON, for consumption and display by a mobile app

Currently, the enum values are strings; what I really need is an object that looks like a string but has some custom behavior. A subclass of String should do nicely:

 module Survey
 class Enum < String
 # Locale scope to use for translations
 class_attribute :i18n_scope

 # Array of all valid values
 class_attribute :valid_values

 def self.values
 @values ||= Array(valid_values).map { |val| new(val) }
 end

 def initialize(s)
 unless s.in?(Array(valid_values))
 raise ArgumentError, "#{s.inspect} is not a valid #{self.class} value"
 end

 super
 end

 def human_name
 if i18n_scope.blank?
 raise NotImplementedError, 'Your subclass must define :i18n_scope'
 end

 I18n.t!(value, scope: i18n_scope)
 end

 def value
 to_s
 end

 def as_json(opts = nil)
 {
 'value' => value,
 'human_name' => human_name
 }
 end
 end
end

This base class handles everything we need: validating the values, converting to human readable names, and exporting to JSON. All we have to do is subclass it and set the two class attributes:

 module Survey
 class Color < Enum
 self.i18n_scope = 'survey.colors'

 self.valid_values = %w(
 black
 brown
 gray
 orange
 yellow
 white
 )
 end

 class HairLength < Enum
 self.i18n_scope = 'survey.hair_lengths'

 self.valid_values = %w(
 less_than_1_inch
 1_to_3_inches
 longer_than_3_inches
 )
 end
end

Finally, we need to add our human readable translations to the locale file:

 en:
 survey:
 colors:
 black: Black
 brown: Brown
 gray: Gray
 orange: Orange
 yellow: Yellow/Blonde
 white: White
 hair_lengths:
 less_than_1_inch: Less than 1 inch
 1_to_3_inches: 1 to 3 inches
 longer_than_3_inches: Longer than 3 inches

We now have an enumerated type in pure Ruby. The values look like strings while also having the custom behavior we need.

 Survey::Color.values

# => ["black", "brown", "gray", "orange", "yellow", "white"]

Survey::Color.values.first.human_name

# => "Black"

Survey::Color.values.as_json

# => [{"value"=>"black", "human_name"=>"Black"}, {"value"=>"brown", "human_name"=>"Brown"}, ...]

The last step is to hook our new enumerated types into our AnswerKey model for great justice. We want color and hair_length to be automatically converted to instances of our new enum classes. Fortunately, my good friend Zachary has already solved that problem. We just have to update our Enum class with the right methods:

 def self.load(value)
 if value.present?
 new(value)
 else
 # Don't try to convert nil or empty strings
 value
 end
end

def self.dump(obj)
 obj.to_s
end

And set up our model:

 class AnswerKey < ActiveRecord::Base
 belongs_to :animal

 serialize :color, Survey::Color
 serialize :hair_length, Survey::HairLength

 validates :color, inclusion: { in: Survey::Color.values }
 validates :hair_length, inclusion: { in: Survey::HairLength.values }
end

BONUS TIP — We probably need to add these enums to a form in the admin interface, right? If you're using Formtastic, it automatically looks at our #human_name method and does the right thing:

 f.input :color, as: :select, collection: Survey::Color.values

Shazam.


 

Hey friend, have you implemented enums in one of your Rails apps? How did you do that? Let me know in the comments below. Have a nice day.

Chris Jones

Chris is a development director who designs and builds clean, maintainable software from our Durham, NC office. He works with clients such as the Wildlife Conservation Society, World Wildlife Fund, and Dick's Sporting Goods.

More articles by Chris

Related Articles