with_options — DRY Validations and Routes Without the Abstraction Tax
Six validations in a row, each ending with the same if: :submitted?. Four routes sharing the same constraint. Three associations with identical dependent: :destroy, inverse_of: options. The repetition is noise, but the usual fix — extracting a method, building a concern, writing a custom validator — costs more indirection than the duplication did. with_options sits exactly in that gap.
What It Does
with_options takes a hash and yields a proxy. Every method called on that proxy gets the hash merged into its final argument.
Example:
class Application < ApplicationRecord
validates :legal_name, presence: true, if: :submitted?
validates :tax_id, presence: true, if: :submitted?
validates :address_line, presence: true, if: :submitted?
validates :postal_code, presence: true, if: :submitted?
validates :contact_email, presence: true, format: { with: EMAIL_RE }, if: :submitted?
end
Example:
class Application < ApplicationRecord
with_options if: :submitted? do |submitted|
submitted.validates :legal_name, presence: true
submitted.validates :tax_id, presence: true
submitted.validates :address_line, presence: true
submitted.validates :postal_code, presence: true
submitted.validates :contact_email, presence: true, format: { with: EMAIL_RE }
end
end
The condition is stated once, at the top, where it reads as a heading rather than as a repeated suffix. Nothing is hidden — the block is right there, the merge is mechanical, and someone reading it doesn’t have to jump to another file to find out what SubmittedValidations does.
The block parameter is conventional but not required; without it, the proxy becomes the implicit receiver:
with_options if: :submitted? do
validates :legal_name, presence: true
end
Naming the parameter is clearer in a long class body, where an unnamed block leaves you wondering which validates calls are inside it. Name it after the condition and the code reads as prose.
Where It Earns Its Place
1. Conditional validation groups
The canonical case, above. Multi-step forms, draft/published states, role-dependent requirements.
2. Associations with shared options
class Account < ApplicationRecord
with_options dependent: :destroy, inverse_of: :account do |assoc|
assoc.has_many :invoices
assoc.has_many :subscriptions
assoc.has_many :api_keys
assoc.has_many :webhooks
end
end
Forgetting inverse_of on one association out of six is a real bug — it causes extra queries and, with validates_presence_of on the parent, spurious validation failures on nested saves. Declaring it once removes the chance of an inconsistency.
3. Routes
Rails.application.routes.draw do
namespace :api do
with_options defaults: { format: :json }, constraints: { id: /\d+/ } do |api|
api.resources :orders
api.resources :invoices
api.resources :customers
end
end
end
4. Scopes and callbacks
class Order < ApplicationRecord
with_options if: :paid?, on: :update do |paid|
paid.after_commit :notify_fulfillment
paid.after_commit :send_receipt
paid.after_commit :update_ledger
end
end
5. Delegation
class OrderPresenter
with_options to: :order, allow_nil: true do |o|
o.delegate :total, :currency, :placed_at
o.delegate :name, to: :customer, prefix: true
end
end
Note the second line — an explicitly passed to: overrides the shared one. Explicit arguments always win over the merged defaults, which is what makes exceptions inside a block possible.
The Merge Semantics You Need to Know
This is where surprises come from. The merge is shallow, applied to the last hash argument.
Example:
with_options presence: true do |required|
required.validates :email, format: { with: EMAIL_RE }
# => validates :email, format: { with: EMAIL_RE }, presence: true
end
Fine. But:
Example:
with_options numericality: { greater_than: 0 } do |positive|
positive.validates :quantity, numericality: { only_integer: true }
# => numericality: { only_integer: true }
# NOT { greater_than: 0, only_integer: true }
end
The inner numericality: replaces the outer one wholesale. Shallow merge means same-key collisions are overwrites, not deep merges. If you need both constraints, state both explicitly on that line.
Multiple conditions don’t combine
with_options if: :submitted? do |s|
s.validates :tax_id, presence: true, if: :business_account?
# Only :business_account? applies — :submitted? is overwritten
end
To require both, pass an array:
s.validates :tax_id, presence: true, if: [:submitted?, :business_account?]
Or restructure with nesting:
with_options if: :submitted? do |s|
s.with_options if: :business_account? do |biz|
# still overwrites — nesting doesn't merge conditions either
end
end
Nesting merges the hashes, and the inner if: still wins on key collision. There is no built-in AND-combination of conditions; use the array form.
Where Not to Use It
with_options is a syntactic convenience. It’s the wrong tool when the grouping represents a real concept.
- When the group has behaviour, not just options. Five validations plus two methods plus a callback is a concern, not a
with_optionsblock. - When you’d reuse it across models.
with_optionsis local to one class body. Shared validation logic belongs in a custom validator or a concern. - When the block gets long. Past ten or so lines, the shared option at the top scrolls off screen and the benefit inverts — a reader in the middle of the block has no idea a condition applies.
- When only two lines share the option. The duplication was cheaper than the block.
The honest test: does the block make the class easier to read for someone seeing it for the first time? If the answer requires explaining with_options first, it isn’t helping.
Pro-Tip: The failure mode that costs real debugging time is a
with_optionsblock that grows past a screen. A developer adds a validation at line 340, inside a block that opened at line 290 withif: :submitted?, and never notices the condition — so the validation silently doesn’t run for draft records, and the bug surfaces months later as bad data rather than as an error. Two defences: keep blocks short enough to see both ends at once, and write a spec that asserts the negative case for each conditional group —expect(Application.new(status: :draft)).to be_validwith none of the submitted fields set. That single spec catches both a missing condition and an accidental one, and it fails immediately when someone adds a validation in the wrong place.
How It Works
Worth knowing, because it explains the limits. with_options returns an ActiveSupport::OptionMerger, which is a BasicObject using method_missing to intercept every call, merge the stored hash into the last argument if it’s a hash (or append it if there isn’t one), and forward to the original receiver.
That’s the whole implementation. Consequences:
- It works with any method that takes an options hash as its final argument — including your own methods, not just Rails’ DSL
- It cannot merge into anything that isn’t the last argument
- Methods taking keyword arguments work in modern Rails, which special-cases them; very old versions did not
- Because it’s
BasicObject, the proxy has almost no methods of its own, soinspecton it is unhelpful when debugging
You can use it on your own DSLs for free:
class Dashboard
with_options refresh: 60, cache: true do |live|
live.add_widget :revenue, span: 6
live.add_widget :signups, span: 3
live.add_widget :churn, span: 3
end
end
Conclusion
with_options removes repeated option hashes without introducing a new name, a new file, or a new concept for the next reader to learn — which is exactly the right trade when the only thing shared is an option, not a behaviour. Use it for conditional validation groups, association defaults, namespaced route constraints, and delegation. Remember the merge is shallow and same-key collisions overwrite rather than combine, keep blocks short enough to see both ends, and reach for a concern the moment the group starts carrying logic instead of configuration.
FAQs
Q1: Does with_options work outside ActiveRecord models?
Yes. It’s defined on Object by ActiveSupport, so it works in any class — presenters, service objects, route files, your own DSLs — on any method whose last argument is an options hash.
Q2: Can I nest with_options blocks?
Yes, and the hashes merge outward-in with the inner one winning on collisions. It reads poorly beyond one level of nesting, so treat two levels as the practical maximum.
Q3: Why is my if: condition being ignored inside the block?
Because that line passes its own if:, which overwrites the shared one — shallow merge, last writer wins. Combine them with an array: if: [:outer_condition, :inner_condition].
Q4: Is there a performance cost?
Only at class-definition time, and it’s negligible — the proxy exists during the block and the merged calls are ordinary method calls. Nothing persists into runtime.
Q5: How do I make one line in the block opt out of the shared option?
Pass the key explicitly with the value you want, including nil or false. Explicit arguments always override the merged defaults.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀