Ruby: Safer Configuration Defaults with Hash#fetch and Blocks
The Problem
Every Ruby project ends up reading configuration from a hash — YAML, JSON, ENV, you name it. The naive way is to write config[:timeout] || 30 or config.fetch(:timeout, 30). Both work until the value is legitimately false or nil and you actually want to keep that falsy value. The || operator treats any falsy value as missing, and fetch with a second argument evaluates the default even when the key exists, which can be wasteful or even dangerous if the default has side effects.
Enter Hash#fetch with a Block
Ruby’s Hash#fetch accepts a block that is executed only when the key is absent. This gives you lazy evaluation, preserves explicit nil/false values, and keeps the call site readable.
# Bad: default evaluated every time
timeout = config.fetch(:timeout, expensive_default())
# Good: block runs only when key missing
timeout = config.fetch(:timeout) { expensive_default() }
The block form also lets you raise a custom error or compute a context‑aware default without polluting the surrounding method.
Real‑World Example: Configuration Loading
Imagine a service that reads a YAML file, merges environment overrides, and then passes the resulting hash to various components. Some components need a timeout, others a retry count, and a few accept false to disable a feature entirely.
require 'yaml'
class Settings
DEFAULTS = {
http_timeout: 5,
max_retries: 3,
enable_cache: true,
log_level: :info
}.freeze
def initialize(raw_hash)
@config = DEFAULTS.merge(raw_hash.transform_keys(&:to_sym))
end
def http_timeout
# Preserve explicit nil/false, compute default lazily
@config.fetch(:http_timeout) { DEFAULTS[:http_timeout] }
end
def max_retries
@config.fetch(:max_retries) { DEFAULTS[:max_retries] }
end
def enable_cache
# Allows false to disable caching
@config.fetch(:enable_cache) { DEFAULTS[:enable_cache] }
end
def log_level
@config.fetch(:log_level) { DEFAULTS[:log_level] }
end
end
# Usage
yaml = YAML.load_file('config.yml')
env_overrides = { http_timeout: ENV.fetch('HTTP_TIMEOUT', nil)&.to_i }.compact
settings = Settings.new(yaml.merge(env_overrides))
puts "Timeout: #{settings.http_timeout}"
puts "Cache enabled? #{settings.enable_cache}"
Notice how enable_cache can be set to false in the YAML and the accessor still returns false instead of falling back to true. The block is never executed because the key exists.
Why It Beats || and fetch(key, default)
- Preserves falsy values.
config[:flag] || trueturnsfalseintotrue. The block form returns the storedfalse. - Lazy evaluation. Expensive computations, network calls, or heavy object instantiation happen only when needed.
- Clear intent. Reading
fetch(:key) { default }signals “use this default only if the key is missing”. - Composable. You can chain
fetchcalls or delegate to other objects inside the block.
Pro tip: combine
fetchwithHash#digfor nested structures:config.dig(:database, :pool) { 5 }(Ruby 3.2+ supports a block ondigas well).
Caveats and Tips
While the block form is powerful, keep a few things in mind:
- Don’t mutate the original hash inside the block; it defeats the purpose of a pure default.
- If you need the default to be a constant, define it once (as in
DEFAULTS) and reference it in the block to avoid re‑creating objects. - For very hot paths, benchmark: a simple
fetch(key, default)with a pre‑computed constant can be marginally faster because it avoids block allocation. In most apps the readability win outweighs the micro‑optimisation.
Wrap‑Up
Using Hash#fetch with a block is a small change that eliminates a whole class of bugs around falsy configuration values and unnecessary work. It expresses intent clearly, works nicely with immutable default objects, and fits naturally into the Ruby idiom of “ask for what you need, provide a fallback only when missing”. Next time you reach for || or a two‑argument fetch, pause and consider the block form — your future self (and your teammates) will thank you.