Ruby Idioms skill for Claude Code
Enumerable over loops, small objects, clean Bundler. Claude writes Ruby that a seasoned Rubyist would merge: filter_map, tally and each_with_object instead of index loops and mutated arrays, Data.define value objects, pattern matching for nested JSON, and modules chosen deliberately between include, extend and prepend. It keeps Bundler locks portable across platforms and adopts RuboCop with a todo file that only shrinks.
- Name
ruby-idioms- Size
- 213 lines · ≈2,000 tokens
- Files
- 1
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes idiomatic Ruby 3.x — blocks, procs, and lambdas, Enumerable chains like filter_map, each_with_object, tally, and lazy, modules as mixins with include, extend, and prepend, Data and Struct value objects, pattern matching, keyword arguments, frozen strings, and Bundler plus RuboCop or Standard setup. Use when writing or reviewing Ruby outside Rails conventions, refactoring loops into Enumerable, designing modules, fixing Bundler or Gemfile.lock issues, or configuring RuboCop. Try asking
- Refactor this method full of each loops and temp arrays into idiomatic Ruby
- Should this shared behavior be a module with include, extend, or prepend?
- Our Gemfile.lock fails to install in the Linux Docker image — fix it
Use it when
- Writing or reviewing plain Ruby, gems, or scripts
- Refactoring imperative loops into Enumerable
- Designing mixins, value objects, or a small DSL
- Setting up or tightening RuboCop or Standard
Skip it when
- Working within Rails models, controllers and conventions — use rails-conventions
- Organizing test factories and fixtures — use test-data-factories
How to check the result
bundle exec rubocop (or standardrb) passes with no new todo entries and the test suite is green under frozen string literals.
The common mistake it prevents
Using Hash.new([]) as a grouping default, which shares one array across every key and corrupts all groups at once.
Done well means: code reads as a short sequence of intention-revealing method calls,
collections are transformed with Enumerable instead of index loops and mutated
accumulators, objects expose small public interfaces, bundle exec rubocop (or
standardrb) passes, and the test suite runs green with frozen string literals on.
Ground rules
- Pick the narrowest Enumerable method that says what you mean (
any?,find,sum,group_by) before reaching foreachorinject. - Keyword arguments for anything beyond one or two obvious positional parameters.
- Small public surface: everything not called from outside is
private. - Prefer composition and plain objects; reach for metaprogramming (
method_missing,define_method,instance_variable_set) only when it removes substantial repetition and stays greppable. # frozen_string_literal: trueat the top of every file (RuboCop enforces it); Ruby 3.4 already warns when mutating literal strings.- Always run commands through Bundler:
bundle exec rspec,bundle exec rake.
Inspect before changing
ruby -v; bundle -v
cat .ruby-version 2>/dev/null; grep -nE "^ruby |rubocop|standard|sorbet|steep" Gemfile
grep -n "BUNDLED WITH" -A1 Gemfile.lock
ls .rubocop.yml .standard.yml sig sorbet 2>/dev/null
Enumerable decision table
| Intent | Use | Instead of |
|---|---|---|
| Transform each | map | each + << |
| Transform and drop nils | filter_map | map + compact |
| Keep / drop | select / reject / partition | each + if |
| First match | find / detect | select.first |
| Any/all/none/how many | any? / all? / none? / count(&:x?) | select.size > 0 |
| Sum | sum, sum { _1.price } | inject(:+) |
| Frequency | tally | each_with_object(Hash.new(0)) |
| Build a hash | to_h { [k, v] }, index_by-style each_with_object({}) | `inject({}) { |
| Group | group_by, chunk_while, slice_when | manual bucketing |
| Batches | each_slice(500) | index math |
| Min/max by key | min_by, max_by, minmax | sort_by.first |
| Big or infinite input | lazy … .first(10) | materializing everything |
# frozen_string_literal: true
totals = orders
.reject(&:cancelled?)
.group_by(&:customer_id)
.transform_values { |os| os.sum(&:total_cents) }
emails = users.filter_map { |u| u.email&.downcase if u.active? }
counts = events.map(&:type).tally # {"click" => 12, "view" => 40}
first_big = File.foreach("huge.log").lazy
.select { it.include?("ERROR") } # `it` param: Ruby 3.4; use _1 on 3.3
.first(5)
Avoid inject with a mutating hash accumulator plus merge inside loops; it is O(n²).
Use each_with_object({}).
Blocks, procs, lambdas
| Construct | Arity check | return behavior | Use for |
|---|---|---|---|
| Block | loose | returns from enclosing method | Iteration, resource scoping |
proc {} | loose | returns from enclosing method | Rarely; prefer lambda |
->(x) {} lambda | strict | returns from lambda | Stored callables, strategies |
method(:name) | strict | normal | Pass existing methods (map(&method(:parse))) |
Resource methods yield and clean up:
def with_tempfile(prefix)
file = Tempfile.new(prefix)
yield file
ensure
file&.close!
end
- Return an
Enumeratorwhen no block is given:return enum_for(__method__) unless block_given?. &:symbolfor single method calls; a full block once arguments or chaining appear.
Value objects and data
Point = Data.define(:x, :y) do # Ruby 3.2+: immutable, keyword or positional init
def +(other) = with(x: x + other.x, y: y + other.y)
def to_s = "(#{x}, #{y})"
end
p1 = Point.new(x: 1, y: 2)
p2 = p1.with(y: 5)
Data.definefor immutable values;Struct.new(..., keyword_init: true)only when mutation is required.- Plain hashes are fine at boundaries (JSON in/out); convert to objects before logic.
Comparable+<=>for orderable values; implementeql?andhashif instances are hash keys (Data does this for you).
Pattern matching
case response
in { status: 200, body: { data: [first, *] } }
handle(first)
in { status: 404 }
nil
in { status: 500.. => code }
raise ServerError, "upstream #{code}"
end
config => { database: { url: String => db_url } } # rightward assignment; raises NoMatchingPatternKeyError if absent
Use for destructuring nested hashes/arrays from JSON. Do not replace simple if chains
on one value with case/in.
Modules
| Mechanism | Effect | Use for |
|---|---|---|
include M | M’s methods become instance methods, after the class in lookup | Shared behavior (Comparable, Enumerable) |
extend M | M’s methods on that object/class only | Class-level DSL helpers |
prepend M | M before the class in lookup; super calls the class | Wrapping methods (instrumentation) |
module_function / extend self | Stateless utility namespace | Pure helper functions |
Refinements (using) | Scoped monkey patches | Core extensions without global patches |
Include Enumerable by defining each:
class Playlist
include Enumerable
def initialize(tracks)
@tracks = tracks.dup.freeze
end
def each(&) = @tracks.each(&)
end
Playlist.new(tracks).sort_by(&:duration).first(3)
Never reopen core classes (String, Hash) globally in application code; use a module
function or a refinement.
Errors
- Custom errors inherit from
StandardError(neverException), grouped under a namespace base class:class Billing::Error < StandardError; end. rescue => erescuesStandardError; neverrescue Exception(catchesInterrupt,SystemExit).- Keep
rescuenarrow and close to the call; re-raise with context usingraise Billing::PaymentFailed, "charge #{id}", cause: e(cause is set automatically inside rescue). - Method-level
rescue/ensurewithoutbeginkeeps methods flat.
Bundler
bundle add faraday # adds to Gemfile with a pessimistic constraint and installs
bundle update --conservative faraday # bump one gem without its shared deps
bundle outdated --strict
bundle lock --add-platform x86_64-linux # CI/containers on Linux when developing on macOS
bundle exec bundler-audit check --update # if bundler-audit is in the Gemfile
- Commit
Gemfile.lockfor apps. For gems, keep dependencies in the.gemspecand the lock out of the published package. - Use
~>constraints with two segments for libraries you trust to follow semver ("~> 2.7").
RuboCop or Standard
- Existing
.rubocop.yml: follow it. New project with no strong opinions:standard(zero config). - Legacy adoption:
bundle exec rubocop --auto-gen-configcreates.rubocop_todo.yml; shrink it over time. Never extend the todo file to hide new offenses. - Autocorrect safely with
rubocop -a; review-A(unsafe) changes individually. - Add
rubocop-performanceandrubocop-rspec/rubocop-railswhere applicable.
Gotchas
Hash.new([])shares one array across all keys; useHash.new { |h, k| h[k] = [] }.||=on a value that can legitimately befalseornilrecomputes every time.and/orbind looser than=:ok = valid? and saved?assigns onlyvalid?. Use&&/||.- Strings from literals are chilled in 3.4;
str << "x"on them warns and will raise in a future version. Use+""orString.newfor mutable buffers. - Keyword vs. positional hash arguments are separate since Ruby 3.0; passing a hash where
keywords are expected needs
**opts. putsdebugging left in code; RuboCopLint/Debuggercatchesbinding.irb, notputs.
Verification
bundle exec rubocop # or bundle exec standardrb
bundle exec rspec # or bundle exec rake test
ruby -w -c path/to/file.rb # syntax + warnings for a single file
All pass with no new todo entries. If types are in use (rbs, steep, or sorbet),
run bundle exec steep check or bundle exec srb tc too.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.