---
name: ruby
description: "Use when writing idiomatic Ruby outside Rails — plain scripts, CLIs, gems, libraries — or refactoring imperative loops into Enumerable chains, designing module mixins, packaging with Bundler, deciding whether metaprogramming is worth it, or setting up Minitest/RSpec. Triggers: 'write this in Ruby', 'refactor this loop with map/reduce', 'build a gem', 'gemspec / Gemfile.lock', 'method_missing or define_method?', 'Minitest vs RSpec', 'FrozenError after Ruby 3.4 upgrade', 'mixin vs inheritance', 'escribir una gema en Ruby', 'refactoritzar amb blocs'. NOT Rails/ActiveRecord/controllers (that is rails)."
tags: [ruby, gems, bundler, metaprogramming, rspec, minitest, enumerable]
recommends: [rails, testing-web, secure-coding]
profiles: []
origin: risco
---

# Idiomatic Ruby, the language

Write Ruby a senior Rubyist would sign off on: expression-oriented, block-driven,
leaning on `Enumerable` and `Comparable` instead of hand-rolled loops, with
`frozen_string_literal` hygiene and a real `Gemfile`/`.gemspec` when you package.

This is **Ruby the language and its non-Rails ecosystem** — blocks, modules, gems,
metaprogramming, Minitest/RSpec. The moment code loads the Rails framework or reaches
for ActiveRecord/ActiveSupport idioms, it is no longer this skill — that is `rails`.

Targets **Ruby 4.0** (released 2025-12-25) on **3.4** semantics: Prism is the default
parser, string literals warn on mutation without a magic comment, `it` is an implicit
block param, and `Set` is now a core class.

## When to use / When NOT to use

**Use when:**

- Authoring or refactoring any plain-Ruby `.rb` file, CLI, script, gem, or library.
- Turning imperative loops into `Enumerable` chains (`map`/`select`/`reduce`/`each_with_object`/`group_by`).
- Designing module mixins, `Comparable`/`Enumerable` inclusion, refinements, or value objects (`Struct`/`Data`).
- Setting up a gem: `bundle gem`, `.gemspec`, SemVer, version constraints, publishing to RubyGems.
- Deciding whether metaprogramming (`method_missing`, `define_method`, `instance_eval`, DSLs) earns its keep.
- Writing tests with Minitest or RSpec, wiring RuboCop/Standard, or adding RBS/Sorbet typing.

**When NOT to use (delegate):**

- Rails apps, ActiveRecord models, controllers, migrations, views, Hotwire -> `rails`.
  The hard line: anything that loads Rails or uses ActiveSupport/ActiveRecord idioms.
- The same task in another language -> `python`, `go`, `rust`, `elixir`, `php`, `typescript`.
- A CI pipeline that runs your Ruby tests -> `github-actions` (this skill writes the tests, not the workflow).

## Mental model

Internalize these before writing a line:

1. **Everything is an expression and returns a value.** `if`, `case`, blocks, method
   bodies all yield a value — name and return it instead of threading a flag.
2. **Everything is an object receiving messages.** `5.times`, `nil.to_a`, `"x".freeze`.
   There are no primitives to special-case.
3. **Reach for `Enumerable` before you write a loop.** A manual index loop in Ruby is
   almost always a `map`/`select`/`reduce` you have not spotted yet.
4. **Prefer returning new values to mutating in place.** Mutation is allowed but it is
   the exception you justify, not the default.
5. **Freeze your string literals.** New files start with `# frozen_string_literal: true`.
6. **Dynamism is the trap, not the feature.** Metaprogramming is a scalpel; if a plain
   method, a `Struct`, or a `Data` does the job, use that.

## Blocks, procs, lambdas, yield

Blocks are the spine of Ruby. A method takes a block implicitly via `yield`, or
explicitly with `&block` when it must store or forward it.

```ruby
# frozen_string_literal: true

# Implicit: yield runs the caller's block and returns its value.
def with_timing
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  result = yield
  [result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - start]
end

# Explicit: capture the block as a Proc to forward or store it.
def retrying(times, &block)
  attempts = 0
  begin
    block.call
  rescue StandardError
    (attempts += 1) < times ? retry : raise
  end
end
```

`proc` vs `lambda` differ in two ways that bite:

| Aspect | `proc` / block | `lambda` / `->` |
|--------|----------------|-----------------|
| Arity  | Lenient — extra args dropped, missing become `nil` | Strict — wrong count raises `ArgumentError` |
| `return` | Returns from the enclosing method (can surprise) | Returns from the lambda only |

Rule: use a **lambda** for a function-like value you call yourself; use a **block/proc**
for the "do this with each element" pattern. For cheap accumulation prefer
`each_with_object` (returns the seed) or `reduce`; use `tap` (returns the receiver) for
side effects and `then` for piping a value through a transform.

```ruby
# Bad: index loop building a result by mutation.
result = []
(0...items.length).each { |i| result << items[i].upcase if items[i].size > 3 }

# Good: intent reads top-to-bottom, no index, no pre-seeded array.
result = items.select { |s| s.size > 3 }.map(&:upcase)
```

## Enumerable & Comparable

Most loops map to one named method. Pick by **intent**, not by habit:

| You want to…                          | Use                          |
|---------------------------------------|------------------------------|
| Transform every element               | `map`                        |
| Keep / drop by predicate              | `select` / `reject`          |
| Fold into one value                   | `reduce` / `sum` / `min_by`  |
| Build a Hash or accumulate into a seed| `each_with_object`           |
| Count occurrences                     | `tally`                      |
| Bucket by a key                       | `group_by`                   |
| First match                           | `find` (`detect`)            |
| Pair index with element               | `each_with_index`            |

Make your own classes first-class collections by including `Enumerable` and defining
`each`; get `<`, `>`, `between?`, `clamp`, `sort` for free by including `Comparable` and
defining `<=>`.

```ruby
# frozen_string_literal: true

class Playlist
  include Enumerable

  def initialize(tracks) = @tracks = tracks
  def each(&) = @tracks.each(&)   # one method unlocks map/select/sort/...
end

class Version
  include Comparable
  attr_reader :major, :minor

  def initialize(major, minor)
    @major = major
    @minor = minor
  end

  def <=>(other) = [major, minor] <=> [other.major, other.minor]
end
```

## Modules & composition

Ruby favors composition through mixins over deep inheritance.

- `include M` — adds `M`'s instance methods to instances (the default mixin).
- `prepend M` — inserts `M` *before* the class in the lookup chain, so `M`'s methods can
  wrap the originals via `super` (cleaner than aliasing for decoration).
- `extend M` — adds `M`'s methods as *class/singleton* methods.

Rule: reach for a **module mixin** when the same behavior is shared across unrelated
classes; reserve inheritance for a genuine is-a hierarchy. Namespace with `module Foo`
to avoid global constant clashes. Need to patch a class without polluting it globally?
Use a **refinement** (`refine … using`) — a scoped, reversible scalpel — never a bare
monkey-patch. Full refinement scoping and `prepend`-wrapping in
[references/metaprogramming.md](references/metaprogramming.md).

## Value objects: Data vs Struct

```ruby
# frozen_string_literal: true

Point  = Data.define(:x, :y)         # immutable value object (Ruby 3.2+)
origin = Point.new(x: 0, y: 0)       # no setters; with(x: 1) returns a copy

Cursor = Struct.new(:x, :y)          # mutable; has setters and is Enumerable
```

| Need                                       | Reach for     |
|--------------------------------------------|---------------|
| Immutable value with equality + `with`     | `Data.define` |
| Mutable record, or array/positional access | `Struct.new`  |

Default to `Data` for value semantics; pick `Struct` only when you genuinely need
mutation or positional/`to_a` behavior.

## Metaprogramming, sparingly

Decision rule, in order: **could a plain method, a `Struct`, or a `Data` do this?**
If yes, do that. Only when the method *names themselves* are dynamic (driven by data or
config) does metaprogramming earn its keep.

- Prefer `define_method` (defines real, introspectable methods) over `method_missing`
  (intercepts at call time, slower, invisible to `respond_to?` and tooling).
- If you *must* use `method_missing`, you **must** also define `respond_to_missing?` —
  otherwise `respond_to?`, `method`, and duck-typing all lie about your object.
- Never `eval` a string built from user input.

```ruby
# Good: dynamic method names, but real methods that introspect and document themselves.
class Config
  def initialize(**data) = @data = data

  %i[host port timeout].each do |key|
    define_method(key)       { @data[key] }
    define_method("#{key}=") { |value| @data[key] = value }
  end
end
```

Building a real DSL, `instance_eval`/`class_eval`, and the full
`method_missing` + `respond_to_missing?` pairing live in
[references/metaprogramming.md](references/metaprogramming.md).

## Gems & Bundler

Bundler is the universal dependency manager. `bundle init` creates a `Gemfile`;
`bundle install` resolves and writes `Gemfile.lock`; run project binaries with
`bundle exec` so they use the locked versions.

- Use the **pessimistic constraint** `~> 2.3` (allows `2.x >= 2.3`, blocks `3.0`) for
  libraries; pin tighter only when forced.
- **Commit `Gemfile.lock` for applications** (reproducible installs); for a *library*
  gem, do not commit the lock — let consumers resolve.
- Scaffold a gem with `bundle gem NAME`, which generates the `lib/`, `test/`/`spec/`,
  `.gemspec`, and Rakefile layout.

```ruby
# my_gem.gemspec
Gem::Specification.new do |spec|
  spec.name        = "my_gem"
  spec.version     = MyGem::VERSION       # SemVer: MAJOR.MINOR.PATCH
  spec.summary     = "One-line description."
  spec.authors     = ["You"]
  spec.files       = Dir["lib/**/*.rb"]
  spec.required_ruby_version = ">= 3.4"
  spec.add_dependency "thor", "~> 1.3"
end
```

Full `bundle gem` tree, publishing flow (`gem build` / `gem push`), and Rake test tasks
are in [references/gems-and-testing.md](references/gems-and-testing.md).

## Ruby 4.0 / 3.4 hygiene

```ruby
# frozen_string_literal: true     # without it, 3.4+ warns under -W:deprecated on mutation

nums.map { it * 2 }               # `it` is the implicit single block param (3.4+)
seen = Set.new([1, 2, 3])         # Set is a core class in 4.0 — no `require "set"`
```

- The magic comment freezes every string literal in the file; mutating one then raises
  `FrozenError`. This is the cause of most "my string froze after upgrading" reports.
- `it` reads cleaner than `_1` for one-arg blocks.
- Ruby 4.0 ships **ZJIT** (a new JIT written in Rust, successor to YJIT) and removed
  `Ractor.yield`/`Ractor#take` in favor of `Ractor::Port`.

## Testing

Both ship a clean path; choose by ceremony tolerance:

| Framework | Pick when… |
|-----------|-----------|
| **Minitest** | You want the default — ships with Ruby, tiny surface, `assert`-style or spec-style, fast. |
| **RSpec** | You want the expressive DSL and large ecosystem (`describe`/`context`/`it`/`expect`, `let`, mocks). |

```ruby
# test/tally_test.rb  (Minitest)
require "minitest/autorun"
require "tally"

class TallyTest < Minitest::Test
  def test_counts_words
    assert_equal({ "a" => 2, "b" => 1 }, Tally.count("a b a"))
  end
end
```

```ruby
# spec/tally_spec.rb  (RSpec)
RSpec.describe Tally do
  subject(:counts) { described_class.count("a b a") }
  it { is_expected.to eq("a" => 2, "b" => 1) }
end
```

Run via `bundle exec rake test` (Minitest) or `bundle exec rspec` (RSpec). Fuller
examples with `let`/`subject`/`context` are in
[references/gems-and-testing.md](references/gems-and-testing.md).

## Linting & typing (optional layers)

- **Linter:** Standard (zero-config, "no bikeshadding" wrapper) when you want one less
  argument; RuboCop (`.rubocop.yml`, every cop opt-in/out) when the team wants control.
- **Types:** two coexisting systems — **RBS** (official signatures in separate `.rbs`
  files, checked by Steep) and **Sorbet** (inline `sig` blocks, with experimental
  inline-RBS-comment support). Add types only on a library's public surface or a hot,
  bug-prone core — not on throwaway scripts.

## Anti-patterns

| Anti-pattern | Why it bites | Do instead |
|--------------|--------------|------------|
| Monkey-patching a core class globally | Action-at-a-distance; collides with gems and future Ruby | A refinement (scoped) or a wrapper/helper |
| `method_missing` without `respond_to_missing?` | `respond_to?`/`method`/duck-typing all lie | Pair them, or prefer `define_method` |
| Mutating a frozen string literal | `FrozenError` after the magic comment | `dup` before mutating, or build a new string |
| `for x in coll` loops | Leaks the loop var; un-idiomatic | `coll.each`/`map`/`select` |
| `rescue Exception` (bare) | Swallows `SignalException`, `NoMemoryError`, exit | `rescue StandardError` (or specific classes) |
| Not committing `Gemfile.lock` for an app | Non-reproducible installs across machines | Commit it for apps; omit only for library gems |
| `eval` of user-derived input | Remote code execution | Parse explicitly; never `eval` untrusted data |
| Over-DSLing config a Hash would serve | Hidden control flow, slow `method_missing` | A plain Hash or `Data` object |

## References

- [references/metaprogramming.md](references/metaprogramming.md) — `define_method`,
  `method_missing` + `respond_to_missing?`, `instance_eval`/`class_eval`, building a DSL,
  refinements vs monkey-patching, `prepend` wrapping.
- [references/gems-and-testing.md](references/gems-and-testing.md) — full `bundle gem`
  tree and `.gemspec`, SemVer + `~>`, publishing, Rakefile, full Minitest + RSpec files,
  RuboCop vs Standard config.
