skip to content

In Ruby, why prefer keyword parameters over a trailing `options = {}` Hash parameter for a method with many optional email settings?

level: middleimportance: should knowfreq 48%

answer

  1. the signature documents itself
  2. typos raise instead of vanishing
  3. || true swallows false
  4. fetch fails late, keywords fail early
  5. the caller's Hash gets mutated

basics

~20 s

Keyword parameters declare every setting and its default in the signature, reject misspelled names, enforce required ones at the call, and bind false correctly. An options Hash silently accepts typos, needs hand-written defaults, and may be the caller's own mutable object.

solid answer

~40 s

With `def send_email(to:, subject: "(no subject)", track_opens: true)` the signature *is* the documentation: every setting, which are required, and each default. Ruby enforces it at the call: a misspelled `subjct:` raises `unknown keyword`, a forgotten `to:` raises `missing keyword`. With `def send_email(options = {})`, `options[:subjct]` is just a key nobody reads, a missing `to` surfaces only when `options.fetch(:to)` runs, and defaults are hand-written, where the idiom `options[:track_opens] || true` turns an explicit `false` into `true`. The Hash may also be the caller's own object, so `options.delete(:cc)` mutates the caller's data. RuboCop's `Style/OptionalBooleanParameter`, enabled by default, flags `async = false` positionals and suggests `async: false`. An options Hash still fits genuinely open-ended data, which is better passed as one keyword such as `headers: {}`.

code

ruby · 17 lines
ruby
def send_email_hash(options = {})
  track = options[:track_opens] || true
  options.delete(:cc)
  track
end

settings = { to: "[email protected]", cc: ["[email protected]"], track_opens: false }
send_email_hash(settings)  # => true   explicit false was lost
settings.key?(:cc)         # => false  caller's Hash was mutated

def send_email(to:, cc: [], track_opens: true)
  track_opens
end

send_email(to: "[email protected]", track_opens: false)  # => false
send_email(to: "[email protected]", trak_opens: false)
# ArgumentError: unknown keyword: :trak_opens

go deeper

for a junior

Recall that keyword parameters list each setting and its default in the def line and reject misspelled names, while an options Hash accepts anything.

for a middle

Explain the || default trap with false, the late failure of fetch compared with a missing keyword, and why an options Hash can alias the caller's object.

for a senior

Lead a migration from options Hashes to keywords, including call-site fixes, the typos it will surface, and which RuboCop cops to enable to keep it that way.

for a principal

Decide the house style for public method signatures, including when free-form data may stay a Hash, and how that rule is enforced across teams.

## Two ways to take optional settings An **options Hash** is a trailing positional parameter with an empty Hash default, the dominant Ruby style before keyword arguments existed: ```ruby def send_email(options = {}) to = options.fetch(:to) subject = options[:subject] || "(no subject)" track_opens = options.fetch(:track_opens, true) # ... end ``` **Keyword parameters** declare the same settings in the signature: ```ruby def send_email(to:, subject: "(no subject)", track_opens: true) # ... end ``` Both are called the same way, `send_email(to: "[email protected]", track_opens: false)`, because Ruby still converts bare `name: value` pairs into a positional Hash for a method that declares no keywords. The difference is everything that happens after the call. ## What keywords give you for free | Concern | Options Hash | Keyword parameters | |---|---|---| | Misspelled name, `subjct:` | stored, never read, no error | `ArgumentError`: unknown keyword | | Required setting missing | `KeyError` only when `fetch(:to)` runs | `ArgumentError` at the call: missing keyword | | Defaults | hand-written in the body | in the signature | | Explicit `false` | lost by `\|\| true` | bound as `false` | | Caller's object mutated | yes, if the caller passed a variable | no | - **Self-documentation.** A reader, an editor and a documentation tool all see the settings in the `def` line instead of reconstructing them from `options[...]` lookups scattered through the body. - **Fail early.** A required keyword is checked before the body runs, so a mail with no recipient never reaches the code that builds it. `options.fetch(:to)` raises too, but only when execution reaches that line, and `options[:to]` just returns `nil`. ## The `||` default trap Hand-written defaults invite a classic bug: 1. `options[:track_opens] || true` is meant to default to `true`. 2. The caller passes `track_opens: false` to disable tracking. 3. `false || true` is `true`, so tracking stays on. The correct Hash form is `options.fetch(:track_opens, true)`, which returns the stored `false`. A keyword `track_opens: true` has no such trap: the default applies only when the keyword is **absent**, and an explicit `false` or `nil` is bound as given. ## Aliasing: whose Hash is it? When a caller builds a Hash once and passes it as a variable, `send_email(settings)`, the parameter `options` refers to **the caller's object**. Code that "consumes" options with `options.delete(:cc)` then mutates the caller's `settings`, which surfaces later as a missing setting in an unrelated retry or a second call. Keywords do not have this problem: each keyword is a separate local variable, and a keyword splat `**opts` is a new Hash built for the call. ## Linters and team rules - RuboCop's `Style/OptionalBooleanParameter` is **enabled by default** and flags `def send_email(to, async = false)`, suggesting `async: false`, because a bare `true` or `false` at a call site says nothing about its meaning. Standard disables this cop. - RuboCop's `Style/OptionHash` flags parameters named `options`, `opts`, `args`, `params` or `parameters` and asks for keyword arguments instead, but it is **disabled by default**, so a team must opt in. - `Style/KeywordParametersOrder` (enabled) asks for optional keywords to come after required ones, keeping signatures readable. ## When a Hash is still the right shape - **Open-ended data**, such as arbitrary mail headers, is not a set of settings. Pass it as the value of one keyword, `headers: {}`, so the named settings stay checked and the free-form part stays a Hash. - **Pass-through options** handed untouched to another layer can use a keyword splat, accepting the loss of typo detection for those extras. - **Existing public APIs** may keep their Hash signature for callers you cannot change, although a codebase with no outside callers is better migrated outright. The general rule: a fixed, known set of settings belongs in keyword parameters; free-form data belongs in a Hash value, named by a keyword. ## How to answer in an interview Lead with the enforcement point, because it is what separates the two styles: keywords make Ruby check names, required settings and defaults at the call, while an options Hash leaves all three to hand-written code in the body. Then give one concrete bug the Hash style causes, the `|| true` default or a silently ignored typo, and close with when a Hash is still the right shape. That order shows you know both the mechanism and the judgement.

  • How would you migrate a widely used `send_email(options = {})` to keywords without breaking callers?
    Callers that write bare `name: value` pairs keep working unchanged once the method declares keywords. Callers passing a Hash variable must change to `send_email(**settings)`. Search for such call sites, convert them in the same change, and let the new unknown-keyword errors reveal every misspelled option the old Hash had been silently ignoring.
  • Why is `options.fetch(:track_opens, true)` correct where `options[:track_opens] || true` is not?
    `Hash#fetch` with a default returns the default only when the key is absent, so a stored `false` comes back as `false`. The `||` form tests truthiness, and since `false` and `nil` are the only falsy values, an explicit `false` is replaced by `true`. A keyword default behaves like `fetch`, applying only when the caller omits the keyword.

saying these in an interview costs you the question

  • options[:flag] || true is a safe way to default a boolean setting.
  • Keyword parameters and an options Hash catch misspelled names equally.
  • The options parameter is always a private copy the method may mutate.
  • RuboCop's Style/OptionHash is enabled by default and enforces keywords.
  • Callers must change every name: value call when a method switches to keywords.