Use when writing or refactoring plain Ruby outside Rails — scripts, CLIs, gems, libraries: Enumerable chains and blocks, module mixins and value objects, Bundler/gemspec packaging, whether metaprogramming earns its keep, Minitest or RSpec, Ruby 3.4/4.0 frozen-string hygiene. NOT Rails, ActiveRecord, or ActiveSupport idioms (that is `rails`).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill ruby --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Ruby?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-ruby)More formats (shields.io, HTML) on the badges page.
---
name: ruby
description: "Use when writing or refactoring plain Ruby outside Rails — scripts, CLIs, gems, libraries: Enumerable chains and blocks, module mixins and value objects, Bundler/gemspec packaging, whether metaprogramming earns its keep, Minitest or RSpec, Ruby 3.4/4.0 frozen-string hygiene. NOT Rails, ActiveRecord, or ActiveSupport idioms (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. Delegate: Rails apps, ActiveRecord models, controllers,
migrations, views, Hotwire — anything that loads Rails or uses ActiveSupport/ActiveRecord
idioms — to [`rails`](../rails/SKILL.md); the same task in another language to
[`python`](../python/SKILL.md), [`go`](../go/SKILL.md), [`rust`](../rust/SKILL.md),
[`elixir`](../elixir/SKILL.md), [`php`](../php/SKILL.md) or
[`typescript`](../typescript/SKILL.md); the CI pipeline that runs your Ruby tests to
[`github-actions`](../github-actions/SKILL.md) — this skill writes the tests, not the workflow.
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.
## 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. RuboCop vs Standard config sits in
[references/gems-and-testing.md](references/gems-and-testing.md).
## 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 |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!