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.
# 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.
# 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.
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.
# 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.
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.
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.
# 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.
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.
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.
# 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.
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.
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.
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.
# 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.
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.
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).
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.
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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).
# 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.
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.
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.
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.
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).
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.
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.
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.
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.
# 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.
# 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.
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.
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.
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.
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.
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.
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.