Eliot Sykes

Magic Numbers in Ruby & How You Make Them Disappear

Magic numbers sound exciting. Maintaining code that uses them is not. Learn from real world Rails projects to see how experienced developers use clarifying constants to avoid magic numbers in their work.

Magic numbers are hardcoded, unexplained values in code. There are 2 magic numbers in the allow_to_comment? method below:

class User < ActiveRecord::Base
  ...
  def allow_to_comment?
    # You can guess what magic numbers
    # 13 & 20 mean below, but it'd be
    # clearer to use explaining
    # constants. See refactoring in
    # next code snippet.
    age_in_years >= 13 &&
      comments_in_last_hour.size <= 20
  end
end

To remove a magic number, introduce a Ruby constant. Constants have values that do not change (unlike variables which do change). Constants have SCREAMING_SNAKE_CASE names:

class User < ActiveRecord::Base
  ...
  # Constants introduced to replace the
  # magic numbers in allow_to_comment?
  COMMENTER_MINIMUM_AGE_IN_YEARS = 13
  MAXIMUM_COMMENTS_PER_HOUR = 20

  def allow_to_comment?
    age_in_years >= COMMENTER_MINIMUM_AGE_IN_YEARS &&
      comments_in_last_hour.size <= MAXIMUM_COMMENTS_PER_HOUR
  end
end

You’ll sometimes find this refactoring technique referred to as “Extract Constant”, “Introduce Constant”, or “Replace Magic Number with Symbolic Constant”.

The refactored code above uses carefully chosen constant names that describe what the numbers mean. Note how the names do not repeat the constant value in words. THIRTEEN_YEARS = 13 and TWENTY_COMMENTS = 20 would not be sensible constants, however the names COMMENTER_MINIMUM_AGE_IN_YEARS and MAXIMUM_COMMENTS_PER_HOUR are sensible.

If the value of a constant changes, then the name of the constant should not need to be updated to stay correct. COMMENTER_MINIMUM_AGE_IN_YEARS is a good name as it does not need to change if the commenter age limit changes from 13 to 18 years.

Above you may have noticed the updated code is longer, but more easily understood compared to the original. Making code longer is neither bad nor good. In this case, longer code happens to be a consequence of writing more understandable code.

Rewriting unclear code into easily understood code is an exercise a seasoned developer will deliberately perform early, while the purpose of their code is fresh in their mind. They’ve learned unclear and forgettable code you write today has a habit of coming back to haunt you tomorrow.

Magic numbers aren’t only limited to numbers. Magic numbers cover any unexplained values, including strings, arrays, dates, regular expressions, and so on.

Be inspired by seeing what constants experienced developers are using to avoid magic numbers in these highlights from real world Rails applications:

# real-world-rails/apps/trailmix/app/mailers/prompt_mailer.rb
PROMPT_TEXT = "How was your day?"

# real-world-rails/apps/huginn/app/models/agents/pushbullet_agent.rb
API_BASE = 'https://api.pushbullet.com/v2/'

# real-world-rails/apps/discourse/config/puma.rb
APP_ROOT = '/home/discourse/discourse'

# real-world-rails/apps/discourse/lib/common_passwords/common_passwords.rb
PASSWORD_FILE = File.join(
  Rails.root, 'lib', 'common_passwords', '10k-common-passwords.txt'
)

# real-world-rails/apps/discourse/app/models/topic_link.rb
MAX_URL_LENGTH = 500

# real-world-rails/apps/discourse/lib/single_sign_on.rb
NONCE_EXPIRY_TIME = 10.minutes

# real-world-rails/apps/canvas-lms/app/models/delayed_message.rb
#
# Using constants to calculate other constants:
MINUTES_PER_DAY = 60 * 24
WEEKLY_ACCOUNT_BUCKETS = 4
MINUTES_PER_WEEKLY_ACCOUNT_BUCKET =
  MINUTES_PER_DAY / WEEKLY_ACCOUNT_BUCKETS

# real-world-rails/apps/discourse/config/routes.rb
USERNAME_ROUTE_FORMAT = /[A-Za-z0-9\_]+/

# real-world-rails/apps/lobsters/app/models/user.rb
BANNED_USERNAMES = ["admin", "administrator", "hostmaster",
  "mailer-daemon", "postmaster", "root", "security", "support",
  "webmaster", "moderator", "moderators", "help", "contact", "fraud",
  "guest", "nobody"]

# real-world-rails/apps/spree/core/app/models/spree/order.rb
SHIPMENT_STATES =
  %w(backorder canceled partial pending ready shipped).freeze

# real-world-rails/apps/spree/core/app/models/spree/credit_card.rb
CARD_TYPES = {
  visa: /^4[0-9]{12}(?:[0-9]{3})?$/,
  master: /(^5[1-5][0-9]{14}$)|(^6759[0-9]{2}([0-9]{10})$)|(^6759[0-9]{2}([0-9]{12})$)|(^6759[0-9]{2}([0-9]{13})$)/,
  diners_club: /^3(?:0[0-5]|[68][0-9])[0-9]{11}$/,
  american_express: /^3[47][0-9]{13}$/,
  discover: /^6(?:011|5[0-9]{2})[0-9]{12}$/,
  jcb: /^(?:2131|1800|35\d{3})\d{11}$/
}

Practice this technique by searching your own work for magic numbers and replace any you discover with well-named constants.

ES ·

Grow Your Rails Expertise Faster