Enum types in Ruby
When I start a new project in Ruby, the first module I copy over is my custom little enum.rb.
Many popular programming languages have built-in support for enums. C, C++, C#, Go, Java, Rust, Swift, but also looser-typed languages like PHP and Python.
Ruby doesn’t have native support for enum types, but it’s not hard to add and add some clarity and cleanliness to your code.
Enums?
Enums, or enumerated types, are a way to define a number of constant values that are all related to the same purpose or parent type. Typical use cases for these are lists of options or flags.
Another way to think about enums is as union types of their members. For example “a ‘Protocol’ is the value ‘http’ OR ‘ftp’ OR ’tftp’”.
Here’s how you may define an enum of protocols in C++, Java, or Rust:
enum Protocol {
HTTP,
FTP,
TFTP
}
Once defined, you can use the enum type for variables and compare those variables to members of the enum.
bool MakeRequest(Protocol proto) {
if (proto == Protocol::HTTP) {
// Do HTTP stuff
}
}
MakeRequest(Protocol::HTTP)
Depending on the use case, you may not ever care about what the underlying value of
each enum member is. By default in most languages, the underlying value of each
enum member starts at 1 and increments in the order the members are defined.
You can usually override this if you need to or want to be explicit about it.
enum Protocol {
HTTP = 80,
FTP = 21,
TFTP = 69
}
If you have to store the value somewhere such as a database or file, then you do start to care and have to be careful about removing or re-adding members.
But why?
An enum can be the single source of truth for a finite list of values that are referenced in your codebase. If a member has to be added or removed, you’re doing it in one place. Your codebase becomes more self-describing as it’s clear what values are being worked with and what options are allowed.
If you see a function MakeRequest(string proto), you end up having to explicitly
document what options are allowed, explicitly validate incoming arguments,
and callers to that function have to make sure they’re sending something valid too.
If you see MakeRequest(Protocol proto) you have a big hint as to what values
are expected by this function.
But Ruby doesn’t use types
True. But it can still benefit from programmers not repeating the same values all over the codebase by hardcoding strings, symbols, integers, whatever else.
A common pattern in Ruby applications is to use symbols. That doesn’t provide
any value with regards to validation or option discovery. If you see :http
somewhere you’re left asking…but what are the other options? Where is this defined? It’s not defined.
Rails has enum
It does have an enum macro that can be used in ActiveRecord models.
class Request < ApplicationRecord
enum :protocol, [:http, :ftp, :tftp]
end
Like most other languages, Rails generates incrementing integer underlying values for each enum member.
In this situation of models, you know these values are being persisted to the database so you have to be very careful about not removing or re-ordering your enum definitions, otherwise your existing data will map to incorrect enums in your model.
You can explicitly re-value them if you want though.
enum :protocol, {http: 80, ftp: 21, tftp:69}
This all works, but has some usability issues:
- The default of storing integers in the database makes the data hard to understand by other apps sharing the database
- You end up hardcoding the labels elsewhere in your code e.g.
record.protocol = "http" - Can’t use enum member labels in pattern matching
- It only works in ActiveRecord models
Enum module for Ruby
I want to be able to use enums outside of models, outside of Rails apps, and have easy access to the values for pattern matching.
My first attempts were just defining modules with constants inside them. This worked, but quickly ran into repeating code for things like listing the options, validating, casting, etc.
My solution: a simple Enum module that makes it possible to write code like this:
class Request
Protocol = Enum.define_from_values("http", "ftp", "tftp")
# @param proto [Protocol]
def make_request(proto)
case proto
in Protocol::HTTP
# http stuff
in Protocol::FTP | Protocol::TFTP
# file stuff
end
end
req = Request.new
req.make_request(Request::Protocol::HTTP)
puts "Protocol options:"
puts Request::Protocol.members.map { |k, v| "#{k} = #{v}" }.join("; ")
There are a few things going on here worth pointing out.
- Our list of protocol values is defined exactly once in our codebase.
- Each enum gets its own proper Ruby type.
- When code references our enum, it’s clear what the source of truth is.
- Each member has its own constant. Useful for pattern matching.
- By default, underlying values are strings matching the label, making them portable and easy to understand in SQL database, JSON, etc.
- Members of an enum can be accessed as a hash and iterated.
Source
# Generates a class that has enum-like behaviour.
# Values are stored as constants an accessible in a hash-like way
#
# Example:
# Status = Enum.define_from_values(:ok, :error)
# Status::OK => :ok
# Status::ERROR => :error
module Enum
# Define an enum with explicit labels and values
#
# Example:
# Protocol = Enum.define({http: "H", ftp: "F", tftp: "T"})
#
# @param members [Hash]
# Hash keys get converted into constants, so only use sensible values.
# @return [Class]
def self.define(members)
raise ArgumentError, "must be a Hash" unless members.is_a?(Hash)
if (duplicates = members.values.tally.select { |_v, c| c > 1 }).any?
raise ArgumentError, "duplicate values are not allowed: #{duplicates.keys}"
end
klass = Class.new
klass.extend(ClassMethods)
members = members.to_h { |key, value| [key, value.freeze] }.freeze
members.each do |key, value|
const_name = key.to_s.parameterize.underscore.upcase.to_sym
klass.const_set(const_name, value)
end
klass.define_singleton_method(:members) { members }
klass
end
# Define an enum with values matching their labels
#
# Example:
# Protocol = Enum.define_from_values("http", "ftp", "tftp")
#
# @param values [Array<String | Symbol>]
# Since values become constants, use only strings or symbols.
# @return [Class]
def self.define_from_values(*values)
define(values.flatten.to_h { [it, it] })
end
module ClassMethods
def to_h
members
end
def [](key)
members.fetch(key)
end
def keys
members.keys.to_set
end
def key(value)
members.key(value)
end
def values
members.values.to_set
end
def valid?(key)
members.key?(key)
end
# Get several wanted values, validating that each is actually a valid enum member
# Example:
# Protocol.values_at("http", "ftp")
# => Set["http", "ftp"]
#
# Protocol.values_at("http", "oscar")
# => ArgumentError "invalid keys requested: oscar"
#
# @param wanted_keys [Array<String | Symbol>]
# @return Set<String | Symbol>
def values_at(*wanted_keys)
wanted_keys = wanted_keys.flatten
if (invalid_keys = wanted_keys - keys.to_a).any?
raise ArgumentError, "invalid keys requested: #{invalid_keys}"
end
members.slice(*wanted_keys).values.to_set
end
end
end
Rails integration
Tying it all together, I add a string_enum method to my base ApplicationRecord
class and override enum so that either way enums are created, I get an Enum
class defined on the model based on that definition.
I prefer storing enum values as strings in the database since it makes them legible to other applications, including developers looking at the raw SQL queries or database.
If you want to, you can even create an ENUM in PostgreSQL to enforce validity at the database level.
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
class << self
# @override Defined by Rails
def enum(name, values = nil, **options)
super
const_name = name.to_s.classify.to_sym
if const_defined?(const_name)
warn("#{const_name} already defined on #{self.name}. Not overwriting")
return
end
# The builtin `enum` method creates a hash of k=>v named by the plural form
# of the enum. Use that to build a local enum class
mapping = public_send(name.to_s.pluralize)
const_set(const_name, Enum.define(mapping))
end
def string_enum(name, values, **options)
values_hash = case values
in Array | Set => arr
arr.map(&:to_s).to_h { [it, it] }
in Hash => h
h.transform_keys(&:to_s).transform_values(&:to_s)
end
if values_hash.empty?
raise ArgumentError, "no enum values provided"
end
enum(name, values_hash, **options)
end
end
end
Use in a record
class User < ApplicationRecord
string_enum :theme, ["light", "dark", "auto"], suffix: true
end
auto_users = User.auto_theme
new_auto_user = User(theme: User::Theme::AUTO)
Wrapping up
I find it a code smell to hardcode values around a codebase. It’s far too easy to introduce typos, introduce bugs during a refactor, or have any grip on a source of truth for such values. Enums can tidy things up nicely and bring some type safety and comprehensibility to your code.