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

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.

~/.claude/skills/ruby-idioms/SKILL.md

--- name: ruby-idioms description: Writes idiomatic Ruby 3.x — blocks, procs, and lambdas, Enumerable chains like filter_map, each_with_object, tally, and lazy, modules as… license: MIT ---

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 for each or inject.
  • 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: true at 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

IntentUseInstead of
Transform eachmapeach + <<
Transform and drop nilsfilter_mapmap + compact
Keep / dropselect / reject / partitioneach + if
First matchfind / detectselect.first
Any/all/none/how manyany? / all? / none? / count(&:x?)select.size > 0
Sumsum, sum { _1.price }inject(:+)
Frequencytallyeach_with_object(Hash.new(0))
Build a hashto_h { [k, v] }, index_by-style each_with_object({})`inject({}) {
Groupgroup_by, chunk_while, slice_whenmanual bucketing
Batcheseach_slice(500)index math
Min/max by keymin_by, max_by, minmaxsort_by.first
Big or infinite inputlazy … .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

ConstructArity checkreturn behaviorUse for
Blockloosereturns from enclosing methodIteration, resource scoping
proc {}loosereturns from enclosing methodRarely; prefer lambda
->(x) {} lambdastrictreturns from lambdaStored callables, strategies
method(:name)strictnormalPass 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 Enumerator when no block is given: return enum_for(__method__) unless block_given?.
  • &:symbol for 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.define for 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; implement eql? and hash if 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

MechanismEffectUse for
include MM’s methods become instance methods, after the class in lookupShared behavior (Comparable, Enumerable)
extend MM’s methods on that object/class onlyClass-level DSL helpers
prepend MM before the class in lookup; super calls the classWrapping methods (instrumentation)
module_function / extend selfStateless utility namespacePure helper functions
Refinements (using)Scoped monkey patchesCore 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 (never Exception), grouped under a namespace base class: class Billing::Error < StandardError; end.
  • rescue => e rescues StandardError; never rescue Exception (catches Interrupt, SystemExit).
  • Keep rescue narrow and close to the call; re-raise with context using raise Billing::PaymentFailed, "charge #{id}", cause: e (cause is set automatically inside rescue).
  • Method-level rescue/ensure without begin keeps 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.lock for apps. For gems, keep dependencies in the .gemspec and 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-config creates .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-performance and rubocop-rspec / rubocop-rails where applicable.

Gotchas

  • Hash.new([]) shares one array across all keys; use Hash.new { |h, k| h[k] = [] }.
  • ||= on a value that can legitimately be false or nil recomputes every time.
  • and / or bind looser than =: ok = valid? and saved? assigns only valid?. Use && / ||.
  • Strings from literals are chilled in 3.4; str << "x" on them warns and will raise in a future version. Use +"" or String.new for mutable buffers.
  • Keyword vs. positional hash arguments are separate since Ruby 3.0; passing a hash where keywords are expected needs **opts.
  • puts debugging left in code; RuboCop Lint/Debugger catches binding.irb, not puts.

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.

esc