Ruby.CodeCompared.To/Crystal

An interactive executable cheatsheet for Rubyists learning Crystal

Ruby 4.0 Crystal 1.9
Syntax Basics
Hello World
puts "Hello, Crystal!"
puts "Hello, Crystal!"

The Crystal hello-world program is byte-for-byte identical to Ruby's. Crystal intentionally inherits Ruby's syntax β€” puts, string literals, and the absence of a main() function wrapper are all shared. Unlike most compiled languages, Crystal code at the top level is the program's entry point, just as in Ruby.

Comments
# Single-line comment count = 42 # inline comment =begin Multi-line comment block (rarely used in practice) =end puts count
# Single-line comment count = 42 # inline comment # Crystal has no =begin/=end. # Multi-line comments just use # repeated # lines. puts count

Both languages use # for comments. Crystal does not support Ruby's rarely-used =begin / =end block comment syntax β€” multi-line comments in Crystal are simply consecutive # lines. Crystal also supports # :nodoc: annotations to hide items from generated documentation.

Semicolons & line endings
# Ruby: semicolons optional greeting = "Hello" name = "World" puts "#{greeting}, #{name}!" # Semicolons allow multiple statements per line (rare) x = 1; y = 2; puts x + y
# Crystal: semicolons also optional β€” same as Ruby greeting = "Hello" name = "World" puts "#{greeting}, #{name}!" # Multiple statements per line with semicolons (rare) x = 1; y = 2; puts x + y

Like Ruby, Crystal infers statement boundaries from newlines and does not require semicolons. Semicolons may be used to separate multiple statements on one line, but this is uncommon in both languages. This is a deliberate contrast with C-family compiled languages like Go and Rust, which either require semicolons or insert them via a lexer rule.

Variables & Types
Type inference
x = 42 message = "hello" ratio = 3.14 puts x.class # Integer puts message.class # String puts ratio.class # Float
x = 42 message = "hello" ratio = 3.14 puts typeof(x) # Int32 puts typeof(message) # String puts typeof(ratio) # Float64

Crystal infers the type of every variable at compile time β€” no annotation is needed. The typeof() macro returns the compile-time type as a string, whereas Ruby's .class returns the runtime class object. A key difference: Crystal's integer literals default to Int32, not a arbitrary-precision integer like Ruby's Integer.

Explicit type annotation
# Ruby has no type annotations in the language itself. # Sorbet or RBS provide optional static typing. count = 0 name = "Alice" puts count puts name
# Crystal: type annotation is optional but explicit count : Int32 = 0 name : String = "Alice" puts count puts name puts typeof(count) puts typeof(name)

Crystal supports optional explicit type annotations using the variable : Type = value syntax. Annotations are rarely needed for local variables because the type inferencer handles them, but they are useful for documenting intent or when the inferencer needs a hint. Instance variable type annotations (@name : String) are required in class definitions.

Constants
MAX_SIZE = 100 PI = 3.14159 APP_NAME = "RubyApp" puts MAX_SIZE puts PI puts APP_NAME
MAX_SIZE = 100 PI = 3.14159 APP_NAME = "CrystalApp" puts MAX_SIZE puts PI puts APP_NAME

Constants look and behave identically in both languages: uppercase names, assigned with =. In Crystal, constants are truly constant β€” the compiler enforces this. In Ruby, reassigning a constant produces only a warning, not an error. Crystal constants are also typed at compile time, just like variables.

Multiple assignment
first, second = 1, 2 puts first puts second # Swap values first, second = second, first puts first puts second # Splat assignment head, *tail = [10, 20, 30, 40] puts head puts tail.inspect
first, second = 1, 2 puts first puts second # Swap values first, second = second, first puts first puts second # Splat assignment head, *tail = [10, 20, 30, 40] puts head puts tail.inspect

Multiple assignment, value swapping, and splat assignment are all syntactically identical to Ruby. Crystal's type inferencer handles each variable independently, giving head the type Int32 and tail the type Array(Int32). This is one of the many areas where Crystal code is simply valid Ruby.

Nil Safety
Nilable vs. non-nilable
# Ruby: any variable can hold nil at any time name = "Alice" puts name.upcase # "ALICE" name = nil # This would crash at runtime: # puts name.upcase # NoMethodError puts name.nil? # true
# Crystal: String cannot be nil; String? can be nil name : String = "Alice" puts name.upcase # ALICE # Nilable type: String? is shorthand for String | Nil maybe_name : String? = nil puts maybe_name.nil? # true # Compiler prevents calling .upcase on String? # without a nil check β€” must narrow the type first: if maybe_name puts maybe_name.upcase end

Crystal's nil safety is enforced by the compiler. A plain String variable can never hold nil β€” that is a compile-time error. The nilable form String? (shorthand for the union type String | Nil) explicitly opts into nilability. The compiler then forces the programmer to perform a nil check before calling any String-only method, preventing the NoMethodError: undefined method for nil class of bugs that are common in Ruby.

Nil checks & narrowing
def greet(name) if name puts "Hello, #{name.upcase}!" else puts "Hello, stranger!" end end greet("Alice") greet(nil)
def greet(name : String?) : Nil if name # Inside this branch, Crystal knows name is String puts "Hello, #{name.upcase}!" else puts "Hello, stranger!" end end greet("Alice") greet(nil)

Crystal performs type narrowing β€” inside an if name branch, the compiler knows name is String (not String | Nil), and all String methods are available without any cast. This pattern is identical to the kind of nil guarding that Ruby programmers write defensively, but Crystal enforces it at compile time rather than relying on programmer discipline.

.try, .not_nil!, and || defaults
maybe_name = nil # Safe navigation operator puts maybe_name&.upcase.inspect # nil # Nil-coalescing with || display = maybe_name || "Anonymous" puts display maybe_name = "Alice" puts maybe_name&.upcase
maybe_name : String? = nil # .try β€” like Ruby's &. safe navigation puts maybe_name.try(&.upcase).inspect # nil # || for default values β€” same as Ruby display = maybe_name || "Anonymous" puts display maybe_name = "Alice" # .not_nil! β€” asserts non-nil, raises if nil puts maybe_name.not_nil!.upcase

Crystal's .try { |value| ... } is the equivalent of Ruby's &. safe navigation operator β€” it calls the block only when the value is not nil. The || operator for nil-coalescing defaults is identical to Ruby. The .not_nil! method asserts that a value is non-nil and raises NilAssertionError at runtime if the assertion fails β€” it is the escape hatch when the programmer knows more than the compiler.

Strings
String literals & Char
# Ruby: both single and double quotes produce strings greeting1 = 'Hello' greeting2 = "Hello" puts greeting1.class # String puts greeting2.class # String puts greeting1 == greeting2 # true
# Crystal: double quotes = String, single quotes = Char! greeting = "Hello" # String letter = 'A' # Char (a single Unicode character) puts typeof(greeting) # String puts typeof(letter) # Char puts greeting.size # 5 # Chars can be compared and converted puts letter # A puts letter.ord # 65

This is one of Crystal's most important gotchas for Rubyists: single-quoted literals are Char, not String. In Ruby, 'hello' and "hello" are both strings β€” only string interpolation differs. In Crystal, 'A' is a single Unicode code point (the Char type), and "A" is a one-character String. Calling String methods on a Char is a compile-time type error.

String interpolation
name = "World" count = 42 puts "Hello, #{name}!" puts "Count is #{count} and doubled is #{count * 2}" puts "2 + 2 = #{2 + 2}"
name = "World" count = 42 puts "Hello, #{name}!" puts "Count is #{count} and doubled is #{count * 2}" puts "2 + 2 = #{2 + 2}"

String interpolation with #{...} is syntactically identical between Ruby and Crystal. Crystal evaluates the expression inside #{} at compile time where possible, and calls .to_s on the result β€” exactly as Ruby does. Any expression is valid inside the braces in both languages.

Heredocs
text = <<~HEREDOC Line one Line two Line three HEREDOC puts text
text = <<-HEREDOC Line one Line two Line three HEREDOC puts text

Crystal uses <<-DELIMITER for heredocs, which strips leading whitespace based on the indentation of the closing delimiter β€” similar to Ruby's <<~DELIMITER squiggly heredoc. Ruby uses <<- for a different purpose (allowing the closing delimiter to be indented without stripping content whitespace), so the two are subtly different in behavior. Crystal heredocs also support interpolation by default.

Common string methods
text = " Hello, World! " puts text.strip puts text.strip.upcase puts text.strip.downcase puts text.strip.size puts text.strip.include?("World") puts text.strip.gsub("World", "Ruby") puts text.strip.split(", ").inspect
text = " Hello, World! " puts text.strip puts text.strip.upcase puts text.strip.downcase puts text.strip.size puts text.strip.includes?("World") puts text.strip.gsub("World", "Crystal") puts text.strip.split(", ").inspect

The core string API is nearly identical between Crystal and Ruby: .strip, .upcase, .downcase, .size, .includes?, .gsub, and .split all exist in Crystal with the same signatures. The question-mark method naming convention (includes?) is shared. Crystal's string methods return new strings (strings are immutable in Crystal), which matches Ruby's default non-bang behavior.

Numbers
Integer types
# Ruby: one Integer type β€” arbitrary precision small = 42 big = 10 ** 100 puts small.class # Integer puts big.class # Integer puts big # a googol!
# Crystal: fixed-size integer types like a systems language small = 42 # inferred as Int32 larger = 10_i64 # explicit Int64 literal explicit : Int64 = 10 puts typeof(small) # Int32 puts typeof(larger) # Int64 puts typeof(explicit) # Int64 # Available types: Int8, Int16, Int32, Int64, Int128 # and their unsigned variants: UInt8, UInt16, etc. puts Int32::MAX # 2147483647

Ruby's single Integer type handles numbers of arbitrary size transparently. Crystal uses fixed-width types common in systems programming: Int32 (the default), Int64, UInt8, and so on. Integer overflow is undefined behavior in release builds and wraps in debug builds. Crystal does not have a big-integer type built into the standard library, reflecting its systems-language heritage.

Arithmetic & division
puts 10 + 3 # 13 puts 10 - 3 # 7 puts 10 * 3 # 30 puts 10 / 3 # 3 (integer division in Ruby 4.0+) puts 10.0 / 3 # 3.3333... puts 10 % 3 # 1 puts 2 ** 10 # 1024
puts 10 + 3 # 13 puts 10 - 3 # 7 puts 10 * 3 # 30 puts 10 / 3 # 3 (integer / integer = integer) puts 10.0 / 3 # 3.3333... puts 10 % 3 # 1 puts 2 ** 10 # 1024

Arithmetic operators are identical between Crystal and Ruby. Integer division (two integer operands) yields an integer in both languages β€” 10 / 3 is 3. To get a float result, at least one operand must be a float. The ** exponentiation operator works the same way. Crystal's arithmetic operates on the fixed-width types, so overflow is possible in a way that Ruby's arbitrary-precision integers prevent.

typeof and type inspection
puts 42.class # Integer puts 3.14.class # Float puts "hi".class # String puts true.class # TrueClass puts [1, 2].class # Array
puts typeof(42) # Int32 puts typeof(3.14) # Float64 puts typeof("hi") # String puts typeof(true) # Bool puts typeof([1, 2]) # Array(Int32) # .class is also available at runtime: puts 42.class # Int32

Crystal's typeof() is a compile-time macro that returns the static type of an expression, while .class returns the runtime type (which may be more specific than the static type in the case of polymorphic objects). Ruby only has .class as a runtime introspection method. The static types in Crystal include precise numeric types like Float64 and parameterized collection types like Array(Int32).

Arrays
Array literals & types
numbers = [1, 2, 3, 4, 5] words = ["hello", "world"] mixed = [1, "two", 3.0] puts numbers.class # Array puts words.class # Array puts mixed.inspect
numbers = [1, 2, 3, 4, 5] words = ["hello", "world"] # Mixed array gets a union type: mixed = [1, "two", 3.0] puts typeof(numbers) # Array(Int32) puts typeof(words) # Array(String) puts typeof(mixed) # Array(Int32 | String | Float64) puts mixed.inspect

Array literal syntax is identical to Ruby. Crystal infers the element type from the literal: [1, 2, 3] becomes Array(Int32). A mixed array like [1, "two", 3.0] gets the union type Array(Int32 | String | Float64) β€” Crystal tracks every possible type. In Ruby, arrays are always heterogeneous containers with no type tracking. Explicit typed arrays use Array(Int32).new.

Array operations
items = [10, 20, 30] items.push(40) items << 50 puts items.first # 10 puts items.last # 50 puts items.size # 5 puts items.include?(30) # true items.each { |item| print "#{item} " } puts
items = [10, 20, 30] items.push(40) items << 50 puts items.first # 10 puts items.last # 50 puts items.size # 5 puts items.includes?(30) # true (note: includes? not include?) items.each { |item| print "#{item} " } puts

Array operations are nearly identical. One notable difference: Crystal uses includes? (with an 's') where Ruby uses include?. The shovel operator <<, push, first, last, size, and each are all the same. Crystal's type system ensures that items.first returns Int32 | Nil when the array might be empty, forcing nil handling.

map, select, reject, reduce
numbers = [1, 2, 3, 4, 5, 6] doubled = numbers.map { |number| number * 2 } evens = numbers.select { |number| number.even? } odds = numbers.reject { |number| number.even? } total = numbers.reduce(0) { |sum, number| sum + number } puts doubled.inspect puts evens.inspect puts odds.inspect puts total
numbers = [1, 2, 3, 4, 5, 6] doubled = numbers.map { |number| number * 2 } evens = numbers.select { |number| number.even? } odds = numbers.reject { |number| number.even? } total = numbers.reduce(0) { |sum, number| sum + number } puts doubled.inspect puts evens.inspect puts odds.inspect puts total

The functional collection methods map, select, reject, and reduce are syntactically identical to Ruby and work exactly the same way. Crystal infers the return types: map { |n| n * 2 } on an Array(Int32) yields a new Array(Int32). These examples compile and run without any modification from their Ruby originals β€” a testament to how closely Crystal mirrors Ruby's design.

Hashes
Hash literals & types
scores = {"Alice" => 95, "Bob" => 87} puts scores["Alice"] puts scores.class # Symbol keys (common Ruby idiom) config = {host: "localhost", port: 8080} puts config[:host]
scores = {"Alice" => 95, "Bob" => 87} puts scores["Alice"] puts typeof(scores) # Hash(String, Int32) # Symbol keys work too config = {host: "localhost", port: 8080} puts config[:host] puts typeof(config) # Hash(Symbol, Int32 | String)

Hash literal syntax using => is identical to Ruby. Crystal infers both key and value types: {"Alice" => 95} becomes Hash(String, Int32). Symbol key shorthand ({host: "localhost"}) also works, mirroring Ruby's syntax. When values have different types (as in the config example), Crystal creates a union-typed hash like Hash(Symbol, Int32 | String).

Hash access & nil safety
scores = {"Alice" => 95, "Bob" => 87} puts scores["Alice"] # 95 puts scores["Charlie"].inspect # nil (missing key returns nil) # fetch raises KeyError for missing key begin scores.fetch("Charlie") rescue KeyError => error puts "Not found: #{error.message}" end puts scores.fetch("Charlie", 0) # default value
scores = {"Alice" => 95, "Bob" => 87} puts scores["Alice"] # 95 # [] raises KeyError for missing key (unlike Ruby!) # []? returns nil safely puts scores["Charlie"]? .inspect # nil begin scores["Charlie"] rescue KeyError puts "Not found!" end puts scores.fetch("Charlie", 0) # default value

An important difference: Crystal's Hash#[] raises KeyError for a missing key (like Ruby's Hash#fetch), while Ruby's [] silently returns nil. Crystal provides the []? variant to get nil-safe access that returns ValueType | Nil. The fetch method with a default value works the same in both languages. Crystal's approach forces explicit handling of missing keys at the call site.

Hash iteration
inventory = {"apples" => 5, "bananas" => 3, "cherries" => 12} inventory.each do |fruit, count| puts "#{fruit}: #{count}" end puts inventory.keys.inspect puts inventory.values.inspect puts inventory.any? { |_, count| count > 10 }
inventory = {"apples" => 5, "bananas" => 3, "cherries" => 12} inventory.each do |fruit, count| puts "#{fruit}: #{count}" end puts inventory.keys.inspect puts inventory.values.inspect puts inventory.any? { |_, count| count > 10 }

Hash iteration with each, keys, values, and any? is syntactically identical to Ruby. The block destructuring of key-value pairs (|fruit, count|) and the use of _ as a discard variable also work the same way. Crystal's type inferencer correctly types each block parameter from the hash's key/value types.

Ranges & Iteration
Range literals
inclusive = (1..10) exclusive = (1...10) puts inclusive.to_a.inspect puts exclusive.to_a.inspect puts inclusive.include?(10) # true puts exclusive.include?(10) # false puts (1..5).sum
inclusive = (1..10) exclusive = (1...10) puts inclusive.to_a.inspect puts exclusive.to_a.inspect puts inclusive.includes?(10) # true (note: includes? not include?) puts exclusive.includes?(10) # false puts (1..5).sum

Range literals (1..10 inclusive and 1...10 exclusive) are syntactically identical to Ruby. The to_a, and sum methods work the same way. As with arrays, Crystal uses includes? (with an 's') rather than Ruby's include?. Crystal ranges are typed: (1..10) is a Range(Int32, Int32).

times, upto, step
3.times { |index| puts "tick #{index}" } 1.upto(5) { |number| print "#{number} " } puts (1..10).step(2) { |number| print "#{number} " } puts
3.times { |index| puts "tick #{index}" } 1.upto(5) { |number| print "#{number} " } puts (1..10).step(2) { |number| print "#{number} " } puts

The integer iteration methods times, upto, and the range step method are syntactically identical in Crystal and Ruby. This is another area where Crystal code is simply valid Ruby. Crystal implements all of these as methods on the integer types rather than as special syntax, consistent with Ruby's design philosophy.

Control Flow
if / elsif / else
score = 85 if score >= 90 puts "A" elsif score >= 80 puts "B" elsif score >= 70 puts "C" else puts "F" end # if as expression grade = if score >= 90 then "A" elsif score >= 80 then "B" else "C" end puts grade
score = 85 if score >= 90 puts "A" elsif score >= 80 puts "B" elsif score >= 70 puts "C" else puts "F" end # if as expression (Crystal does not support inline `then` on the same line) grade = if score >= 90 "A" elsif score >= 80 "B" else "C" end puts grade

The if / elsif / else / end syntax is identical to Ruby, including the use of elsif (not else if or elif). Like Ruby, Crystal's if is an expression that returns a value, so it can be used on the right-hand side of an assignment. The type of the expression is the union of the types of all branches.

unless, postfix if/unless
logged_in = false unless logged_in puts "Please log in." end # Postfix if and unless puts "Access granted" if logged_in puts "Access denied" unless logged_in count = 5 puts "Has items" if count > 0
logged_in = false unless logged_in puts "Please log in." end # Postfix if and unless β€” identical to Ruby puts "Access granted" if logged_in puts "Access denied" unless logged_in count = 5 puts "Has items" if count > 0

Crystal supports unless and postfix if/unless, both inherited directly from Ruby. These are not found in most compiled languages, making Crystal feel unusually expressive for a statically typed language. The postfix forms are idiomatic in both languages for simple one-line guards.

case / when
status = :active case status when :active puts "User is active" when :suspended, :banned puts "User is restricted" when :pending puts "Awaiting approval" else puts "Unknown status" end
status = :active case status when :active puts "User is active" when :suspended, :banned puts "User is restricted" when :pending puts "Awaiting approval" else puts "Unknown status" end

The case / when / else / end syntax is identical to Ruby. Crystal also supports type-based matching in when clauses β€” when String, when Int32 β€” which narrows the type of the matched variable in each branch, providing the same type-narrowing benefit that Crystal's if is_a?(Type) provides. This is more powerful than Ruby's case because the compiler enforces the narrowed type.

while / until / loop
count = 0 while count < 3 puts "while: #{count}" count += 1 end count = 3 until count == 0 puts "until: #{count}" count -= 1 end
count = 0 while count < 3 puts "while: #{count}" count += 1 end count = 3 until count == 0 puts "until: #{count}" count -= 1 end

The while and until loop constructs are syntactically identical to Ruby. Crystal also supports loop do ... end for an infinite loop with break to exit, just like Ruby. The next (skip to next iteration) and break (exit loop) keywords work identically in both languages.

Methods
Basic method definition
def greet(name) "Hello, #{name}!" end puts greet("Alice") puts greet("World")
def greet(name) "Hello, #{name}!" end puts greet("Alice") puts greet("World")

Basic method definition with def / end and implicit return of the last expression is identical to Ruby. Crystal infers the return type from the method body β€” no annotation is needed. The method signature above is valid Crystal even without type annotations, and the compiler will infer that greet accepts any type that has .to_s defined (similar to duck typing, but verified at compile time).

Type-annotated methods
# Ruby: no built-in type annotations # (Sorbet / RBS are external tools) def add(first_number, second_number) first_number + second_number end puts add(3, 4) puts add(1.5, 2.5)
# Crystal: optional type annotations inline def add(first_number : Int32, second_number : Int32) : Int32 first_number + second_number end puts add(3, 4) # Overload for floats β€” Crystal supports method overloading def add(first_number : Float64, second_number : Float64) : Float64 first_number + second_number end puts add(1.5, 2.5)

Crystal supports optional parameter and return type annotations using param : Type and a trailing : ReturnType. Unlike Ruby, Crystal supports method overloading β€” multiple methods with the same name but different type signatures. The compiler selects the correct overload at each call site based on the argument types. This is a significant capability that Ruby lacks.

Default & named arguments
def greet(name = "World", punctuation: "!") "Hello, #{name}#{punctuation}" end puts greet puts greet("Alice") puts greet("Alice", punctuation: "?") puts greet(punctuation: ".")
def greet(name = "World", punctuation = "!") "Hello, #{name}#{punctuation}" end puts greet puts greet("Alice") # Named arguments at call site: puts greet("Alice", punctuation: "?") puts greet(punctuation: ".")

Default argument values work identically to Ruby. A difference: Crystal does not require a bare keyword argument declaration in the method signature β€” any parameter can be passed by name at the call site using name: value syntax. Ruby requires explicit keyword parameter declaration (def greet(punctuation:) vs def greet(punctuation)). Crystal's approach is more flexible but less self-documenting.

Splat & ? / ! suffixes
def sum(*numbers) numbers.sum end puts sum(1, 2, 3) # 6 puts sum(10, 20) # 30 def empty?(collection) collection.empty? end puts empty?([]) # true puts empty?([1, 2]) # false
def sum(*numbers : Int32) numbers.sum end puts sum(1, 2, 3) # 6 puts sum(10, 20) # 30 def empty?(collection) collection.empty? end puts empty?([] of Int32) # true puts empty?([1, 2]) # false

The splat operator * for variadic arguments works the same as Ruby. Crystal collects splat arguments into a Tuple rather than an Array, but .sum works on both. The ? and ! method name suffix conventions are fully supported β€” they are part of Crystal's identifier rules, inherited directly from Ruby. The [] of Int32 syntax creates an empty typed array literal.

Blocks & Closures
Blocks and yield
def repeat(count) count.times { yield } end repeat(3) { puts "Hello!" } def transform(value) result = yield value puts "Transformed: #{result}" end transform(5) { |number| number * 10 }
def repeat(count) count.times { yield } end repeat(3) { puts "Hello!" } def transform(value) result = yield value puts "Transformed: #{result}" end transform(5) { |number| number * 10 }

The block and yield mechanism is syntactically identical to Ruby. Crystal resolves blocks at compile time β€” they are inlined or represented as closures, but the programmer sees the same interface. Both { ... } and do ... end block forms work identically to Ruby. Crystal's block system is one of the most faithful inheritances from Ruby in the language design.

Procs and lambdas
square = ->(number) { number * number } puts square.call(5) # 25 puts square.(6) # 36 double = proc { |number| number * 2 } puts double.call(7) # 14 numbers = [1, 2, 3, 4, 5] puts numbers.map(&method(:puts)).inspect
# Crystal Procs require type annotations on parameters square = ->(number : Int32) { number * number } puts square.call(5) # 25 double = ->(number : Int32) { number * 2 } puts double.call(7) # 14 # Short proc syntax with & shorthand numbers = [1, 2, 3, 4, 5] doubled = numbers.map { |number| number * 2 } puts doubled.inspect

Crystal's Proc literals use the same ->(param) { body } syntax as Ruby's lambda, but Crystal requires explicit type annotations on the parameters (->(number : Int32) { ... }). Crystal does not have a separate proc { } constructor β€” there is only the lambda-style Proc. The .call method works identically. The type of a Proc is written as Int32 -> Int32 or Proc(Int32, Int32).

Block shorthand with &
words = ["hello", "world", "ruby"] # Symbol-to-proc shorthand puts words.map(&:upcase).inspect puts words.map(&:length).inspect puts words.select(&:frozen?).inspect
words = ["hello", "world", "crystal"] # Crystal's &. shorthand β€” called "short block" syntax puts words.map(&.upcase).inspect puts words.map(&.size).inspect # Works on any method name β€” type-safe at compile time numbers = [1, -2, 3, -4, 5] puts numbers.select(&.positive?).inspect puts numbers.map(&.abs).inspect

Crystal's &.method_name shorthand (called "short block" syntax) is similar to Ruby's &:symbol symbol-to-proc shorthand. The syntax differs: Ruby uses &:upcase, Crystal uses &.upcase. Crystal's version is more powerful β€” it allows chaining (&.strip.upcase) and method calls with arguments (&.includes?("x")), and it is checked at compile time against the element type.

Classes
Class definition
class Person attr_accessor :name, :age def initialize(name, age) @name = name @age = age end def to_s "#{@name} (age #{@age})" end end person = Person.new("Alice", 30) puts person puts person.name person.age = 31 puts person.age
class Person # Instance variables must declare their type property name : String property age : Int32 def initialize(@name : String, @age : Int32) end def to_s(io : IO) : Nil io << "#{@name} (age #{@age})" end end person = Person.new("Alice", 30) puts person puts person.name person.age = 31 puts person.age

Several important differences appear here. Instance variables in Crystal must have a declared type β€” @name : String. The property macro (like Ruby's attr_accessor) generates both getter and setter. Crystal's initialize shorthand allows @name : String directly in the parameter list, which simultaneously declares the type and assigns the instance variable. The to_s override in Crystal takes an IO object and writes to it, rather than returning a string.

getter, setter, property
class Product attr_reader :name # getter only attr_writer :price # setter only attr_accessor :stock # both getter and setter def initialize(name, price, stock) @name = name @price = price @stock = stock end end product = Product.new("Widget", 9.99, 100) puts product.name product.price = 12.99 product.stock = 95 puts product.stock
class Product getter name : String # getter only setter price : Float64 # setter only property stock : Int32 # both getter and setter def initialize(@name : String, @price : Float64, @stock : Int32) end end product = Product.new("Widget", 9.99, 100) puts product.name product.price = 12.99 product.stock = 95 puts product.stock

Crystal's getter, setter, and property macros are direct analogs of Ruby's attr_reader, attr_writer, and attr_accessor, respectively. The difference is that Crystal's macros also declare the instance variable's type from the type annotation provided. This elegantly combines the type declaration with the accessor generation in a single line.

Inheritance
class Animal attr_reader :name def initialize(name) @name = name end def speak "..." end def describe "#{@name} says: #{speak}" end end class Dog < Animal def speak "Woof!" end end class Cat < Animal def speak "Meow!" end end puts Dog.new("Rex").describe puts Cat.new("Whiskers").describe
class Animal getter name : String def initialize(@name : String) end def speak : String "..." end def describe : String "#{@name} says: #{speak}" end end class Dog < Animal def speak : String "Woof!" end end class Cat < Animal def speak : String "Meow!" end end puts Dog.new("Rex").describe puts Cat.new("Whiskers").describe

Inheritance uses the same class Dog < Animal syntax as Ruby. Method overriding works identically β€” no override keyword is required. Crystal resolves method dispatch at compile time when the type is known statically, and at runtime (via a vtable) when it is not. The super keyword for calling parent methods also works the same as in Ruby.

Structs & Value Types
struct vs. class
# Ruby: everything is a reference type β€” no value types # Struct in Ruby is still a reference type (a class alias) Point = Struct.new(:x, :y) point_a = Point.new(1, 2) point_b = point_a # both point to same object point_b.x = 99 puts point_a.x # 99 β€” both reflect the change puts point_b.x # 99
# Crystal: struct is a VALUE type (copied on assignment) struct Point property x : Int32 property y : Int32 def initialize(@x : Int32, @y : Int32) end end point_a = Point.new(1, 2) point_b = point_a # point_b is a COPY of point_a point_b.x = 99 puts point_a.x # 1 β€” point_a is unchanged puts point_b.x # 99

This is one of Crystal's most significant departures from Ruby. Crystal struct is a value type: assignment copies the entire struct. In contrast, class is a reference type β€” assignment copies only a pointer. Ruby's Struct.new creates a reference type (a class), so all references share the same object. Crystal structs are stack-allocated and have no garbage-collection overhead, making them suitable for small, frequently-created data like points, colors, and coordinates.

When to use struct
# Ruby uses classes for everything class Color attr_reader :red, :green, :blue def initialize(red, green, blue) @red = red @green = green @blue = blue end def to_s "rgb(#{@red}, #{@green}, #{@blue})" end end puts Color.new(255, 128, 0)
# Crystal: struct is ideal for small value objects struct Color getter red : UInt8 getter green : UInt8 getter blue : UInt8 def initialize(@red : UInt8, @green : UInt8, @blue : UInt8) end def to_s(io : IO) : Nil io << "rgb(#{@red}, #{@green}, #{@blue})" end end puts Color.new(255, 128, 0)

The guideline in Crystal is: use struct for small, immutable (or value-semantics) data objects β€” coordinates, colors, vectors, timestamps, and similar. Use class for objects with identity, mutable state shared across references, or complex behavior. The Crystal standard library itself uses structs for Int32, Float64, Char, and other primitive types.

Modules & Mixins
Module and include
module Greetable def greet "Hello, I am #{name}!" end end class Person include Greetable attr_reader :name def initialize(name) @name = name end end puts Person.new("Alice").greet
module Greetable def greet : String "Hello, I am #{name}!" end end class Person include Greetable getter name : String def initialize(@name : String) end end puts Person.new("Alice").greet

The module keyword and include for mixin composition are syntactically identical to Ruby. Crystal performs structural type checking on mixins at compile time β€” the module's method name must exist on any class that includes Greetable, and the compiler will error if it does not. This is a compile-time enforcement of what Ruby does at runtime via duck typing.

extend and module methods
module MathHelpers def square(number) number * number end def cube(number) number ** 3 end end class Calculator extend MathHelpers end puts Calculator.square(4) # 16 puts Calculator.cube(3) # 27
module MathHelpers def square(number : Int32) : Int32 number * number end def cube(number : Int32) : Int32 number ** 3 end end class Calculator extend MathHelpers end puts Calculator.square(4) # 16 puts Calculator.cube(3) # 27

The extend keyword adds module methods as class-level (not instance-level) methods β€” this is identical in behavior to Ruby. Including a module with include adds methods to instances; using extend adds methods to the class itself. Crystal handles both at compile time, selecting the correct method dispatch based on the call receiver's static type.

Modules as namespaces
module Geometry class Circle def initialize(radius) @radius = radius end def area Math::PI * @radius ** 2 end end end circle = Geometry::Circle.new(5) puts circle.area.round(2)
module Geometry class Circle def initialize(@radius : Float64) end def area : Float64 Math::PI * @radius ** 2 end end end circle = Geometry::Circle.new(5.0) puts circle.area.round(2)

Modules as namespaces work identically to Ruby β€” classes and other modules can be nested inside a module, and the :: operator accesses nested constants. The Crystal standard library uses this extensively, with namespaces like HTTP::Client, JSON::Builder, and IO::Memory that follow the same pattern as Ruby's standard library.

Error Handling
begin / rescue / ensure
begin result = 10 / 0 rescue ZeroDivisionError => error puts "Caught: #{error.message}" ensure puts "Always runs" end puts "Continuing..."
begin result = 10 / 0 rescue error : DivisionByZeroError puts "Caught: #{error.message}" ensure puts "Always runs" end puts "Continuing..."

Crystal's exception handling with begin / rescue / ensure / end is nearly identical to Ruby. One syntactic difference: Crystal writes the rescue clause as rescue error : ErrorType (type annotation style), while Ruby uses rescue ErrorType => error (fat-arrow assignment style). Crystal's exception names also differ from Ruby's in some cases β€” it's DivisionByZeroError rather than Ruby's ZeroDivisionError.

raise and custom exceptions
class ValidationError < StandardError def initialize(field, message) super("#{field}: #{message}") @field = field end end def validate_age(age) raise ValidationError.new("age", "must be positive") if age < 0 raise ValidationError.new("age", "must be under 150") if age > 150 age end begin validate_age(-5) rescue ValidationError => error puts "Invalid: #{error.message}" end puts validate_age(25)
class ValidationError < Exception getter field : String def initialize(@field : String, message : String) super("#{@field}: #{message}") end end def validate_age(age : Int32) : Int32 raise ValidationError.new("age", "must be positive") if age < 0 raise ValidationError.new("age", "must be under 150") if age > 150 age end begin validate_age(-5) rescue error : ValidationError puts "Invalid: #{error.message}" end puts validate_age(25)

Custom exceptions in Crystal use class MyError < Exception (Ruby uses StandardError as the typical base class; Crystal uses Exception). The raise keyword and the pattern of raising and rescuing exceptions are otherwise identical. Crystal's type-specific rescue error : ValidationError syntax ensures that only the declared exception type is caught, and the compiler narrows error's type inside the rescue block accordingly.

Multiple rescue clauses
def risky_operation(value) case value when 0 raise ZeroDivisionError, "division by zero" when -1 raise ArgumentError, "negative not allowed" else 100 / value end end [-1, 0, 5].each do |number| begin puts risky_operation(number) rescue ZeroDivisionError => error puts "Math error: #{error.message}" rescue ArgumentError => error puts "Arg error: #{error.message}" end end
def risky_operation(value : Int32) case value when 0 raise DivisionByZeroError.new when -1 raise ArgumentError.new("negative not allowed") else 100 // value end end [-1, 0, 5].each do |number| begin puts risky_operation(number) rescue DivisionByZeroError puts "Math error: division by zero" rescue error : ArgumentError puts "Arg error: #{error.message}" end end

Multiple rescue clauses for different exception types work the same way as in Ruby β€” Crystal evaluates them top to bottom and takes the first matching branch. When the exception variable is not needed, the type annotation can be omitted (rescue DivisionByZeroError without a variable name). Crystal checks exception types at compile time; if an exception type does not exist in scope, the compiler reports an error rather than silently ignoring the rescue clause.