Basics & Syntax
Hello, World
fun main() {
println("Hello, World!")
} puts "Hello, World!" No
main, no braces, no semicolons, and no parentheses on the call — the file body is the program. Ruby drops nearly every piece of syntax that is not carrying meaning, which is bracing after Kotlin and takes about a week to stop feeling like something is missing.No val, no var, no types
Kotlin makes you declare mutability and (often) the type. Ruby asks for neither — a variable springs into existence on first assignment and can hold anything.
fun main() {
val fixed = 1
var counter = 0
counter += 1
// fixed = 2 ← compile error
val explicit: Long = 42
println("${fixed + counter} $explicit")
} counter = 0 # no keyword, no type, always reassignable
counter += 1
FIXED = 1 # SCREAMING_CASE means constant — reassigning WARNS,
# but Ruby still lets it happen
number = 42
number = "now a string" # and now it holds a String. Nobody objects.
puts "#{FIXED + counter} #{number}" A capitalized name makes a constant, and Ruby's idea of "constant" is a warning printed to stderr rather than an error — the reference can be reassigned, and the object it points at can usually be mutated regardless. The
val discipline is entirely on you. What Ruby does freeze, as of 4.0, is string literals: "hello" << " world" now raises FrozenError, which is the language quietly conceding the point about immutability.String interpolation
fun main() {
val name = "Ada"
val age = 36
println("$name is $age")
println("Next year: ${age + 1}")
val raw = """
Dear $name,
Regards
""".trimIndent()
println(raw)
} name = "Ada"
age = 36
puts "#{name} is #{age}"
puts "Next year: #{age + 1}"
puts 'single quotes do NOT interpolate: #{name}'
letter = <<~HEREDOC
Dear #{name},
Regards
HEREDOC
puts letter Every slot needs the full
#{…} — there is no bare-identifier shorthand like Kotlin's $name, so the syntax is uniform. The quote style matters, though, and this trips people: single-quoted strings do no interpolation and no escape processing at all. The squiggly heredoc (<<~) is Kotlin's raw string plus trimIndent() in one operator — the tilde is what strips the leading indentation.Symbols: a type Kotlin has no name for
The unfamiliar primitive. A symbol is an interned, immutable name — the identity of a thing rather than the text of it — and it is everywhere in idiomatic Ruby.
// Kotlin has no symbol type. Names of things are Strings,
// or (better) enum entries when the set of names is known.
enum class Status { ACTIVE, INACTIVE }
fun main() {
val statusKey = "status"
val person = mapOf(statusKey to "active", "name" to "Ada")
println(person["status"])
println(Status.ACTIVE)
println(Status.ACTIVE == Status.valueOf("ACTIVE"))
} person = { status: "active", name: "Ada" } # keys here are SYMBOLS
puts person[:status]
puts person.keys.inspect
puts :status.equal?(:status) # true — the same object, always
puts "status".equal?("status") # false — two different String objects
# Symbols are how Ruby names methods, keys, and states:
puts "hello".respond_to?(:upcase)
puts [3, 1, 2].sort_by(&:itself).inspect Read
:status as "the name status." Because symbols are interned, the same symbol is always the identical object — cheap to compare, cheap to use as a hash key, and self-documenting where Kotlin would reach for an enum or a string constant. They are also how you refer to a method reflectively (respond_to?(:upcase), send(:upcase)) and the basis of the &:symbol shorthand you will see in the Blocks section. { status: "active" } is sugar for { :status => "active" }.Dynamic Typing
Duck typing replaces interfaces
Nothing declares that it implements anything. The method is looked up by name at the moment of the call, and if it is there, it works.
interface Describable {
fun describe(): String
}
class Dog : Describable {
override fun describe() = "a dog"
}
class Robot : Describable {
override fun describe() = "a robot"
}
fun announce(thing: Describable) {
println("This is ${thing.describe()}")
}
fun main() {
announce(Dog())
announce(Robot())
} class Dog
def describe = "a dog"
end
class Robot # no interface, no superclass, no relationship to Dog
def describe = "a robot"
end
def announce(thing)
puts "This is #{thing.describe}"
end
announce(Dog.new)
announce(Robot.new)
# The honest way to ask, when you must:
puts Dog.new.respond_to?(:describe) "If it responds to
describe, it is describable" — that is the entire contract, and it is checked when the call happens, not before. Pass an object without the method and you get a NoMethodError at runtime, on that code path, in production. The upside is that any object can substitute for any other (test doubles need no interface and no mocking framework), and the culture leans on respond_to? rather than type checks. Note the endless method syntax def describe = "a dog" — Ruby borrowed Kotlin's expression-bodied functions.Everything is an object
Kotlin gets close —
42.toString() works — but primitives, statics, and void still leak through. In Ruby there is no leak: there are no primitives and no static members, only objects and messages.fun main() {
println(42.toString())
println(42::class.simpleName)
// Kotlin has no receiver for null, no methods on Unit,
// and Int is a primitive under the hood (boxed only when needed).
repeat(3) { print("hi ") }
println()
} puts 42.class # Integer
puts nil.class # NilClass — even nil is an object
puts true.class # TrueClass
puts Integer.class # Class — the CLASS is an object too
puts 42.method(:+).class # Method
3.times { print "hi " } # the loop is a method ON the number
puts
puts (-5).abs
puts 255.to_s(2) # bases, on the integer itself Integers have methods,
nil has methods, classes are themselves objects of class Class, and even a method can be reified into a Method object and passed around. 3.times { … } is not a loop construct — it is an ordinary method on Integer that takes a block, which is the pattern the whole language is built from. Nothing is special-cased, so nothing needs an escape hatch: there is no boxing, no static, and no primitive/reference split to reason about.Compile errors become runtime errors
The bill for everything above. Mistakes Kotlin rejects at build time survive in Ruby until the line actually runs.
class Person(val name: String)
fun main() {
val person = Person("Ada")
println(person.name)
// println(person.nmae) ← compile error: unresolved reference
// Kotlin will not build a program containing this typo.
} class Person
attr_reader :name
def initialize(name)
@name = name
end
end
person = Person.new("Ada")
puts person.name
begin
puts person.nmae # typo — nothing catches it until this line executes
rescue NoMethodError => error
puts "NoMethodError: #{error.message}"
end A misspelled method is a
NoMethodError raised the moment that branch runs, and Ruby cannot know in advance whether that misspelled name might have been defined at runtime by some metaprogramming elsewhere — which is precisely why static analysis of Ruby is so hard. The ecosystem's answer is a test suite so thorough it doubles as the type checker (this is where TDD culture came from, and it is not a coincidence). Optional typing exists — Sorbet and RBS/Steep — but it is bolted on, opt-in, and far from universal.nil & Null Safety
nil is null, unguarded
There is no
T?, so there is no distinction to enforce: any reference can be nil, and only running the code will tell you.fun main() {
val ages = mapOf("Ada" to 36)
val age: Int? = ages["Grace"]
println(age)
// println(age + 1) ← compile error: age might be null
println(if (age != null) age + 1 else 0)
} ages = { "Ada" => 36 }
age = ages["Grace"] # nil — no error, no warning
p age
begin
puts age + 1 # boom, at runtime
rescue NoMethodError => error
puts "NoMethodError: #{error.message}"
end
puts age.nil?
puts age.to_i # 0 — nil converts rather than complaining The error message is worth reading closely:
undefined method '+' for nil. Ruby did not "get a null" — it sent the message + to the NilClass singleton, which does not understand it. That is why nil has methods at all (nil.to_a, nil.to_s, nil.inspect), and why nil-related bugs surface as NoMethodError rather than as a null-pointer dereference. Also note p versus puts: p prints the inspect form, so a nil shows as nil instead of an empty line.?. becomes &. and ?: becomes ||
The operators survived the trip — Ruby borrowed safe navigation directly, and
|| stands in for elvis with one important caveat.class Address(val city: String?)
class Person(val address: Address?)
fun main() {
val person = Person(Address(null))
val city = person.address?.city ?: "unknown"
println(city)
person.address?.city?.let { println(it.uppercase()) }
val count: Int? = 0
println(count ?: 99) // prints 0 — only NULL triggers elvis
} Address = Struct.new(:city)
Person = Struct.new(:address)
person = Person.new(Address.new(nil))
city = person.address&.city || "unknown"
puts city
person.address&.city&.then { |name| puts name.upcase } # nothing printed
count = 0
puts count || 99 # prints 0 — Integer 0 is TRUTHY in Ruby
flag = false
puts (flag || 99) # prints 99 — false IS falsy, so || fires
puts flag.nil? ? 99 : flag # the precise form when you mean "nil only" &. is ?. exactly — call the method, or return nil if the receiver is nil — and &.then { } covers ?.let { }. But || is not elvis: it tests falsiness, and while Ruby is unusually sane here (only nil and false are falsy — 0 and "" are truthy, unlike JavaScript or Python), a legitimate false value will still trip it. When you mean "nil only," say so with .nil?. Ruby also has ||=, which is value = value ?: ….The dig / fetch / compact toolkit
Without a type system to lean on, Ruby developed a vocabulary for handling absence at the call site. These are the idioms that replace Kotlin's null-safe types.
fun main() {
val config = mapOf("db" to mapOf("host" to "localhost"))
// Kotlin: the type system already tracks all of this
val host = (config["db"] as? Map<*, *>)?.get("host") ?: "default"
println(host)
val values = listOf(1, null, 3)
println(values.filterNotNull())
val ages = mapOf("Ada" to 36)
println(ages.getOrElse("Grace") { -1 })
} settings = { db: { host: "localhost" } }
puts settings.dig(:db, :host) # safe deep access, nil if any hop misses
puts settings.dig(:cache, :host).inspect # nil, no exception
puts [1, nil, 3].compact.inspect # drop the nils
ages = { "Ada" => 36 }
puts ages.fetch("Grace", -1) # explicit default
puts ages.fetch("Grace") { |key| "no #{key}" } # or compute one
begin
ages.fetch("Grace") # no default => it RAISES
rescue KeyError => error
puts "KeyError: #{error.message}"
end dig walks a nested structure and returns nil the moment a hop misses — a chain of &. for data. compact is filterNotNull. The one to internalize is fetch: unlike hash[key], which quietly hands back nil, fetch either takes an explicit default or raises — making "this key must exist" a statement the code enforces. Reaching for fetch instead of [] is the closest Ruby gets to declaring a non-nullable type, and it turns a mystery NoMethodError three frames later into a precise KeyError right here.Blocks & Lambdas
Blocks and yield
Kotlin's trailing lambda is a parameter you declare. Ruby's block is a syntactic slot every method silently has, invoked with
yield — which is why so much of Ruby reads like a DSL.fun repeatTwice(action: () -> Unit) {
action()
action()
}
fun <T> withTiming(label: String, block: () -> T): T {
println("start $label")
val result = block()
println("end $label")
return result
}
fun main() {
repeatTwice { println("hi") }
val total = withTiming("sum") { (1..4).sum() }
println(total)
} def repeat_twice
yield # no parameter declared — every method can take a block
yield
end
def with_timing(label)
puts "start #{label}"
result = yield # the block's value comes back from yield
puts "end #{label}"
result
end
repeat_twice { puts "hi" }
total = with_timing("sum") { (1..4).sum }
puts total
puts block_given? # false — you can ask whether a block was passed No parameter, no function type, no declaration: the block is passed out-of-band and
yield calls it. This is what makes the resource-management idiom (File.open(path) { |file| … }, Kotlin's use) universal rather than a special feature, and it is why every Ruby API that could take a block does. The multi-line form uses do … end instead of braces (convention: braces for one-liners, do/end for blocks that span lines). Note that yield here has nothing to do with coroutines — it means "call the block," not "suspend."Lambdas, procs, and the & shorthand
Blocks are not objects. When you want to store a callable in a variable, you need a lambda — and
& is the operator that converts between the two worlds.fun main() {
val double: (Int) -> Int = { it * 2 }
println(double(21))
println(listOf(1, 2, 3).map { it * 2 })
println(listOf("a", "b").map(String::uppercase))
val numbers = listOf(1, 2, 3)
println(numbers.map(double))
} double = ->(number) { number * 2 } # a lambda IS an object
puts double.call(21)
puts double.(21) # shorthand for .call
puts [1, 2, 3].map { |number| number * 2 }.inspect
# &:symbol — "make a block that calls this method on each element"
puts %w[a b].map(&:upcase).inspect
# & also converts a lambda INTO a block:
puts [1, 2, 3].map(&double).inspect A lambda is created with
->(args) { } and called with .call — never with bare parentheses, because double(21) would look for a method named double. The star of the show is &:upcase: the & converts its operand to a block, and a symbol converts to "call this method on the argument," so map(&:upcase) is Kotlin's map(String::uppercase) with less to type. Ruby also has Proc, which differs from a lambda in how it handles return and arity — a distinction you can safely ignore until it bites you.Scope functions: tap and then
Kotlin's
let, apply, also, and run have Ruby counterparts — fewer of them, and mercifully easier to keep straight.data class Person(var name: String = "", var age: Int = 0)
fun main() {
val person = Person().apply {
name = "Ada"
age = 36
}
println(person)
val length = "hello".let { it.length }
println(length)
listOf(1, 2, 3)
.also { println("before: $it") }
.map { it * 2 }
.also { println("after: $it") }
} Person = Struct.new(:name, :age)
person = Person.new.tap do |instance| # tap == "also": returns the receiver
instance.name = "Ada"
instance.age = 36
end
p person
length = "hello".then { |text| text.length } # then == "let": returns the block's value
puts length
result = [1, 2, 3]
.tap { |items| puts "before: #{items.inspect}" }
.map { |number| number * 2 }
.tap { |items| puts "after: #{items.inspect}" }
p result Two functions cover Kotlin's four:
tap yields the receiver and returns it (Kotlin's also, and with a mutable object it does the job of apply too), while then — also spelled yield_self — yields the receiver and returns the block's value (Kotlin's let). Since blocks always take an explicit parameter, there is no this-versus-it confusion to untangle. tap is the debugging tool of choice: drop it anywhere in a chain to peek at the value without disturbing it.Collections & Enumerable
Arrays and hashes
Two literal types carry almost everything. There is no read-only/mutable split — an
Array is always mutable unless you freeze it.fun main() {
val numbers = listOf(1, 2, 3)
val mutable = mutableListOf(1, 2, 3)
mutable.add(4)
val ages = mapOf("Ada" to 36)
val unique = setOf(1, 2, 2, 3)
println(numbers)
println(mutable)
println(ages["Ada"])
println(unique)
println(numbers.first())
} numbers = [1, 2, 3] # always mutable
numbers << 4 # << is push
ages = { "Ada" => 36 }
unique = [1, 2, 2, 3].uniq
words = %w[ada grace] # %w is a whitespace-delimited string array
p numbers
puts ages["Ada"]
p unique
p words
puts numbers.first
puts numbers[-1] # negative indexes count from the end
p numbers[1..2] # ranges slice
frozen = [1, 2, 3].freeze # the closest thing to listOf()
puts frozen.frozen? Immutability is a runtime flag (
freeze), not a type — so the compile-time guarantee that List<T> gives you does not exist, and a frozen array raises FrozenError only when someone actually tries to modify it. What you get in exchange is syntax: << to append, negative indexing, range slicing, %w[] for word arrays, and hash keys that need no quoting when they are symbols. Set exists but needs require "set" and is rarely reached for; uniq on an array covers most of its uses.Enumerable is the stdlib you know
This is the most comfortable page in the transition: every Kotlin collection function has an Enumerable counterpart, usually one word away.
fun main() {
val names = listOf("ada", "grace", "alan")
println(names.filter { it.length > 3 }.map { it.uppercase() })
println(names.first { it.startsWith("a") })
println(names.sumOf { it.length })
println(names.any { it.length > 4 })
println(names.sortedBy { it.length })
println(names.groupBy { it.first() })
println(names.joinToString(" | "))
println((1..4).fold(0) { total, number -> total + number })
} names = %w[ada grace alan]
p names.select { |name| name.length > 3 }.map(&:upcase)
puts names.find { |name| name.start_with?("a") }
puts names.sum(&:length)
puts names.any? { |name| name.length > 4 }
p names.sort_by(&:length)
p names.group_by { |name| name[0] }
puts names.join(" | ")
puts (1..4).reduce(0) { |total, number| total + number }
p names.each_with_object([]) { |name, list| list << name.upcase }
p names.partition { |name| name.length > 3 } The dictionary:
filter is select (and filterNot is reject), first { } is find/detect, fold is reduce/inject, joinToString is join, and predicates end in a question mark (any?, all?, none?, include?) — a naming convention Kotlin lacks and which makes conditionals read beautifully. Everything else — map, group_by, sort_by, sum, partition, zip, flat_map, take_while — carries the same name. And any class that defines each can include Enumerable to get all of it, which the Mixins section shows.Sequence becomes Enumerator::Lazy
Like Kotlin, Ruby's collection methods are eager by default and laziness is opt-in — the same trade, spelled differently.
fun fibonacci() = sequence {
var current = 0
var next = 1
while (true) {
yield(current)
val sum = current + next
current = next
next = sum
}
}
fun main() {
println(fibonacci().take(8).toList())
val found = (1..1_000_000).asSequence()
.map { it * it }
.first { it > 50 }
println(found)
} fibonacci = Enumerator.new do |yielder|
current, following = 0, 1
loop do
yielder << current # this yield DOES suspend — it is a Fiber
current, following = following, current + following
end
end
p fibonacci.take(8)
found = (1..1_000_000).lazy
.map { |number| number * number }
.find { |square| square > 50 }
puts found
# Without .lazy this would build a million-element array first:
p (1..Float::INFINITY).lazy.select(&:even?).first(5) .lazy is asSequence(), and Enumerator.new { |yielder| … } is sequence { yield(…) } — including infinite generators, since the enumerator suspends between values (it is a Fiber underneath, which is the concurrency section's subject). Note the collision in vocabulary: the yield keyword calls a block, while this yielder << is what actually suspends and emits a value. Same word, two unrelated jobs.Classes & Open Classes
Classes, attr_accessor, and @
No primary constructor, no property syntax — instance variables carry an
@ sigil, and accessors are generated by a macro.class Person(val name: String, var age: Int) {
fun birthday() {
age += 1
}
fun greet() = "Hi, I'm $name ($age)"
}
fun main() {
val person = Person("Ada", 36)
person.birthday()
println(person.greet())
println(person.name)
} class Person
attr_reader :name # generates the getter (val)
attr_accessor :age # generates get AND set (var)
def initialize(name, age)
@name = name # @ marks an INSTANCE variable
@age = age
end
def birthday = @age += 1
def greet = "Hi, I'm #{@name} (#{@age})"
end
person = Person.new("Ada", 36)
person.birthday
puts person.greet
puts person.name attr_reader is val and attr_accessor is var — and they are not keywords but ordinary class methods that generate the accessors at load time, your first sight of metaprogramming as everyday tooling. An undeclared @variable reads as nil rather than failing, so a typo in an ivar name is silent. Methods are public by default (as in Kotlin), and private is a method that makes everything after it private, not a per-member modifier.Open classes: extension functions without the guardrails
🚨 The one to be careful with. Kotlin's extension functions are resolved statically and are scoped to an import. Ruby lets you reopen any class — including
String — and the change is global, permanent, and visible to every line of code in the process.// Statically resolved, scoped to the import, invisible elsewhere:
fun String.shout(): String = uppercase() + "!"
fun main() {
println("hello".shout())
// Kotlin CANNOT do this — the real String class is closed:
// fun String.length() = 99 ← would not override anything
} class String # reopening the actual, real String class
def shout = upcase + "!"
end
puts "hello".shout # every String everywhere now has this method
# And you really can redefine what already exists:
class Integer
def +(other) = 42 # DO NOT DO THIS
end
puts 1 + 1 # 42. The whole program is now broken.
# The safe version: refinements are scoped, like Kotlin's extensions.
module Politeness
refine String do
def whisper = downcase + "..."
end
end
puts "HELLO".respond_to?(:whisper) # false outside the refinement's scope This is real monkey patching, and the
Integer#+ example is not a joke — it works, and it poisons every arithmetic operation in the process, including the standard library's. Rails was built on this power and the community spent a decade learning to fear it. refine is the sanctioned answer: a refinement is lexically scoped and activated with using, which is roughly Kotlin's extension-function model retrofitted onto a dynamic language. Most code still just reopens the class and relies on discipline.Everything is open, and super is implicit
Kotlin closes classes by default and demands
open/override. Ruby has none of those keywords — nothing is closed, and nothing is announced.open class Animal(val name: String) {
open fun speak() = "..."
fun describe() = "$name says ${speak()}"
}
class Dog(name: String) : Animal(name) {
override fun speak() = "Woof" // "override" is MANDATORY
}
fun main() {
println(Dog("Rex").describe())
} class Animal
attr_reader :name
def initialize(name) = @name = name
def speak = "..."
def describe = "#{name} says #{speak}"
end
class Dog < Animal
def speak = "Woof" # no "override" keyword — it just replaces it
def initialize(name)
super # bare super forwards the SAME arguments
@tricks = []
end
end
puts Dog.new("Rex").describe
puts Dog.ancestors.inspect Redefining
speak in a subclass simply replaces it — there is no override keyword to write and therefore no compiler to tell you that you have accidentally shadowed a method (or misspelled one you meant to replace). Bare super with no parentheses is a Ruby quirk worth memorizing: it forwards the current method's arguments implicitly, while super() with empty parens passes none. And single inheritance is the only kind — everything else comes from mixins, next.Modules & Mixins
Interfaces become mixins
A module is a bag of methods with no instances.
include one and its methods become instance methods of your class — interface plus default implementation plus multiple inheritance, all at once.interface Greeter {
val name: String
fun greet() = "Hello, $name" // default implementation
}
interface Farewell {
val name: String
fun goodbye() = "Bye, $name"
}
class Person(override val name: String) : Greeter, Farewell
fun main() {
val person = Person("Ada")
println(person.greet())
println(person.goodbye())
} module Greeter
def greet = "Hello, #{name}" # calls a method the HOST must provide
end
module Farewell
def goodbye = "Bye, #{name}"
end
class Person
include Greeter
include Farewell
attr_reader :name
def initialize(name) = @name = name
end
person = Person.new("Ada")
puts person.greet
puts person.goodbye
puts Person.ancestors.inspect # the modules sit IN the lookup chain
puts Person.include?(Greeter) The mixin's methods are inserted into the ancestor chain, so this is real inheritance of implementation — not the interface-with-default-methods compromise Kotlin and Java arrived at, and with no diamond problem because the chain is linearized. The contract runs the other way, too:
Greeter#greet calls name, and nothing checks that the host class provides it until the call happens. Modules also serve as namespaces (Math::PI) and, with extend instead of include, add class methods rather than instance methods.Comparable and Enumerable, for free
The payoff for mixins. Define one method, include a module, and inherit an entire protocol — this is how Ruby gets what Kotlin needs operator overloads and interface conformance for.
data class Version(val major: Int, val minor: Int) : Comparable<Version> {
override fun compareTo(other: Version): Int =
compareValuesBy(this, other, { it.major }, { it.minor })
}
fun main() {
val versions = listOf(Version(2, 1), Version(1, 9))
println(versions.sorted())
println(versions.max())
println(Version(1, 0) < Version(1, 1))
} class Version
include Comparable # define <=> and get < > <= >= == between
attr_reader :major, :minor
def initialize(major, minor)
@major = major
@minor = minor
end
def <=>(other) = [major, minor] <=> [other.major, other.minor]
def to_s = "#{major}.#{minor}"
end
versions = [Version.new(2, 1), Version.new(1, 9)]
puts versions.sort.map(&:to_s).inspect
puts versions.max
puts Version.new(1, 0) < Version.new(1, 1)
puts Version.new(1, 5).between?(Version.new(1, 0), Version.new(2, 0))
puts Version.new(1, 5).clamp(Version.new(1, 0), Version.new(1, 2)) Define the spaceship operator
<=>, include Comparable, and you get <, >, <=, >=, ==, between?, clamp, plus sort, min, and max on any collection of them — every operator implemented in terms of the one method you wrote. Enumerable works the same way: define each, include Enumerable, and your class gains map, select, reduce, and the rest of that 50-method protocol. Kotlin's nearest equivalent is implementing Comparable and Iterable, but it hands back nothing like this much.Pattern Matching
when becomes case / in
Ruby 3 added real structural pattern matching. It destructures further than Kotlin's
when — and, having no types to lean on, guarantees far less.sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()
fun area(shape: Shape): Double = when (shape) {
is Circle -> 3.14159 * shape.radius * shape.radius
is Rectangle -> shape.width * shape.height
// Exhaustive: no else needed, and adding a subclass breaks the build.
}
fun main() {
println(area(Rectangle(3.0, 4.0)))
println(area(Circle(1.0)))
} Circle = Struct.new(:radius)
Rectangle = Struct.new(:width, :height)
def area(shape)
case shape
in Circle[radius] # deconstructs the struct
3.14159 * radius * radius
in Rectangle[width, height]
width * height
else
raise ArgumentError, "unknown shape" # you MUST write this yourself
end
end
puts area(Rectangle.new(3.0, 4.0))
puts area(Circle.new(1.0))
# It matches structure, not just types:
config = { database: { host: "localhost", port: 5432 } }
case config
in { database: { host: String => host, port: Integer => port } }
puts "#{host}:#{port}"
end Patterns bind while they match (
Circle[radius] pulls the field straight into a local), and hash patterns can descend into nested structures with type constraints along the way — genuinely more expressive than when. What is gone is the guarantee: with no sealed and no exhaustiveness check, a case/in that matches nothing raises NoMatchingPatternError at runtime, so the else is on you. (Ruby's older case/when still exists and uses ===, matching classes, ranges, and regexes — it is the when-shaped one; case/in is the pattern-matching one.)No sealed, no exhaustiveness
The structural consequence. Kotlin's
sealed closes a hierarchy so the compiler can prove you handled every case. Ruby has no way to close anything — a new subclass can appear at runtime, from a gem, in another file.sealed class Result
data class Success(val value: Int) : Result()
data class Failure(val reason: String) : Result()
fun describe(result: Result): String = when (result) {
is Success -> "ok: ${result.value}"
is Failure -> "failed: ${result.reason}"
// Add a third subclass and this WILL NOT COMPILE until it is handled.
}
fun main() {
println(describe(Success(42)))
println(describe(Failure("nope")))
} Success = Struct.new(:value)
Failure = Struct.new(:reason)
def describe(result)
case result
in Success[value] then "ok: #{value}"
in Failure[reason] then "failed: #{reason}"
end
end
puts describe(Success.new(42))
puts describe(Failure.new("nope"))
# Add a case the code never anticipated...
Pending = Struct.new(:since)
begin
describe(Pending.new("today"))
rescue NoMatchingPatternError => error
puts "NoMatchingPatternError: #{error.class} — found at RUNTIME, not build time"
end The failure mode is at least loud: an unmatched
case/in raises NoMatchingPatternError rather than falling through silently (which is what Python's match does). But it is still a runtime discovery on whatever code path happens to hit it, and no tool will tell you in advance that a new variant went unhandled. Exhaustiveness — the property that makes sealed + when such a powerful refactoring tool in Kotlin, where adding a variant makes the compiler walk you to every site that must change — has no counterpart here. Your test suite is the only thing standing in for it.Metaprogramming
Defining methods at runtime
In Kotlin, code generation means an annotation processor, a compiler plugin, or KSP. In Ruby it is a method call — and it is how
attr_accessor itself works.// Kotlin: the class shape is fixed at compile time. Generating
// members requires KSP/kapt and a build step. The runtime equivalent
// is a map plus manual delegation:
class Settings {
private val values = mutableMapOf<String, String>()
fun set(key: String, value: String) { values[key] = value }
fun get(key: String): String? = values[key]
}
fun main() {
val settings = Settings()
settings.set("host", "localhost")
println(settings.get("host"))
} class Settings
FIELDS = %i[host port adapter] # %i makes an array of SYMBOLS
FIELDS.each do |field|
define_method(field) { @values[field] } # a real getter
define_method("#{field}=") { |value| @values[field] = value }
end
def initialize = @values = {}
end
settings = Settings.new
settings.host = "localhost" # methods that did not exist in the source
settings.port = 5432
puts settings.host
puts settings.port
p Settings.instance_methods(false).sort The class body is executable code, running at load time with
self set to the class — so a loop in it can define methods, and define_method takes a block that becomes the body (closing over field, which is why each generated method remembers its own key). This is exactly how attr_accessor, Rails' has_many, and every Ruby DSL are built. The cost is that no editor and no static analyzer can reliably tell you what methods a class has, because the answer is "whatever ran."method_missing and send
The last resort, and the reason Ruby feels unbounded: a class can answer for methods that were never defined at all.
fun main() {
// Kotlin's nearest equivalent is reflection — and it can only
// find members that actually exist:
val text = "hello"
val method = String::class.members.first { it.name == "length" }
println(method.call(text))
// There is no hook for "someone called a method I do not have."
// The compiler simply rejects the call site.
} class Ghost
def method_missing(name, *args)
if name.to_s.start_with?("say_")
"I say #{name.to_s.delete_prefix("say_")}!"
else
super # ALWAYS fall back, or you break NoMethodError entirely
end
end
def respond_to_missing?(name, include_private = false)
name.to_s.start_with?("say_") || super
end
end
ghost = Ghost.new
puts ghost.say_hello # never defined anywhere
puts ghost.say_goodbye
puts ghost.respond_to?(:say_anything)
# send calls a method BY NAME — including private ones:
puts "hello".send(:upcase)
puts [3, 1, 2].public_send(:sort).inspect When method lookup fails, Ruby calls
method_missing before raising — so an object can synthesize behavior on demand (this is how OpenStruct, ActiveRecord's dynamic finders, and most XML/JSON builder libraries work). Two rules keep it civilized: always super for names you do not handle, or you will swallow every genuine typo in your program; and always pair it with respond_to_missing?, so respond_to? does not lie about what the object can do. send is the other half — invoking a method from a symbol computed at runtime, which Kotlin can only approach through reflection, and never for a member that does not exist.Concurrency
Threads and the GVL
Ruby has real OS threads and a familiar API. It also has a global lock, so they will not use more than one core.
import kotlinx.coroutines.*
suspend fun compute(number: Int): Int {
delay(10)
return number * number
}
fun main() = runBlocking {
val results = coroutineScope {
(1..4).map { number ->
async { compute(number) }
}.awaitAll()
}
println(results)
} def compute(number)
sleep 0.01
number * number
end
threads = (1..4).map do |number|
Thread.new { compute(number) } # a real OS thread...
end
results = threads.map(&:value) # .value joins AND returns the result
p results
mutex = Mutex.new
total = 0
(1..4).map { |number| Thread.new { mutex.synchronize { total += number } } }.each(&:join)
puts total Thread#value joins and returns the block's result, so it plays the part of Deferred#await — but the GVL (Global VM Lock) means only one thread runs Ruby bytecode at a time. Threads still help with I/O (the lock is released during blocking calls), yet CPU-bound work gets no parallelism at all, where Kotlin would saturate every core through Dispatchers.Default. Real parallelism means processes, or Ractor — still experimental after several releases. There is also no structured concurrency: a bare Thread.new is owned by nobody, and an exception inside it vanishes silently unless you call join/value or set abort_on_exception.Fibers are the coroutines
The closest thing to a Kotlin coroutine: a Fiber is a cooperatively-scheduled block that can suspend itself and be resumed, with no thread and no lock involved.
fun countdown() = sequence {
yield("three")
yield("two")
yield("one")
yield("liftoff")
}
fun main() {
val steps = countdown().iterator()
println(steps.next())
println(steps.next())
println(countdown().toList())
} countdown = Fiber.new do
Fiber.yield "three" # suspend, hand the value back to resume
Fiber.yield "two"
Fiber.yield "one"
"liftoff" # the final value
end
puts countdown.resume # three
puts countdown.resume # two
puts countdown.resume # one
puts countdown.resume # liftoff
puts countdown.alive? # false — it ran to completion Fiber.yield genuinely suspends — the stack is preserved, control returns to whoever called resume, and the fiber picks up exactly where it left off. That is the same machinery Kotlin's suspend compiles to, exposed as an explicit object rather than a compiler transformation. Fibers are what Enumerator (and therefore every lazy sequence in the language) is built on, and since Ruby 3 the Fiber::Scheduler hook lets libraries like Async make I/O suspend a fiber instead of blocking a thread — an event loop assembled from userland pieces, which is roughly where Kotlin's coroutines started.Errors & Exceptions
begin / rescue / ensure
Same unchecked-exception model, different keywords — plus a retry mechanism the JVM never offered.
fun parseAge(text: String): Int {
val number = text.toIntOrNull()
?: throw IllegalArgumentException("not a number: $text")
require(number >= 0) { "age cannot be negative" }
return number
}
fun main() {
for (input in listOf("36", "-1", "oops")) {
try {
println(parseAge(input))
} catch (exception: IllegalArgumentException) {
println("error: ${exception.message}")
} finally {
// always runs
}
}
val outcome = runCatching { parseAge("oops") }
println(outcome.getOrElse { -1 })
} def parse_age(text)
number = Integer(text, exception: false)
raise ArgumentError, "not a number: #{text}" if number.nil?
raise ArgumentError, "age cannot be negative" if number.negative?
number
end
["36", "-1", "oops"].each do |entry|
begin
puts parse_age(entry)
rescue ArgumentError => error
puts "error: #{error.message}"
ensure
# always runs
end
end
# A method body is an implicit begin — no keyword needed:
def safe_parse(text)
parse_age(text)
rescue ArgumentError
-1
end
puts safe_parse("oops") The translation is mechanical —
throw is raise, catch is rescue, finally is ensure — and neither language has checked exceptions, so nothing forces a caller to handle anything. Two Ruby-isms to pick up: a method body is an implicit begin block, so rescue can sit at the method level with no nesting; and retry inside a rescue re-runs the begin block from the top, which makes exponential-backoff loops trivial and has no Kotlin equivalent. There is no runCatching/Result — exceptions really are the mechanism.