You've been writing Crystal code that looks like Ruby, but underneath there's a fundamental difference: Ruby interprets your code at runtime, while Crystal compiles it beforehand. This distinction affects everything from performance to what features are possible.
Think of it this way: Ruby is like jazz improvisation - flexible, dynamic, making decisions in the moment. Crystal is like a symphony - planned, rehearsed, and executed flawlessly because everything was figured out in advance.
Every Crystal program lives in two distinct worlds:
This is when the Crystal compiler:
- Reads your source code
- Checks all types
- Resolves all method calls
- Expands all macros
- Optimizes everything
- Generates machine code
Nothing from your program actually runs yet. The compiler is just analyzing and transforming your code.
This is when your compiled binary:
- Actually executes
- Processes data
- Responds to input
- Does the work you wanted
By this point, all decisions have been made. There's no compiler, no type checking, just fast machine code running.
In Ruby, everything happens at runtime:
# Ruby decides at runtime what + means for these objects
def add(a, b)
a + b
end
# Ruby looks up the method at runtime
user.send(:save)
# Ruby evaluates this string as code at runtime
eval("puts 'hello'")
# Ruby can define methods at runtime
define_method(:greet) { puts "hi" }In Crystal, most things are decided at compile time:
# Crystal knows at compile time what + means
def add(a, b)
a + b
end
# Crystal has already figured out which method to call
user.save
# NO eval - compiler isn't available at runtime
# eval("puts 'hello'") # Doesn't exist!
# Macros define methods at compile time (more on this later)
macro define_greeter
def greet
puts "hi"
end
endLet's be honest about what Crystal can't do because of compile-time constraints:
# Ruby: Evaluate arbitrary code at runtime
code = "2 + 2"
result = eval(code) # => 4
# Crystal: NOPE
# The compiler isn't around at runtime to compile new code# Ruby: Handle unknown methods at runtime
class Magic
def method_missing(name, *args)
"You called #{name} with #{args}"
end
end
Magic.new.anything(1, 2, 3) # => "You called anything with [1, 2, 3]"Crystal has method_missing, but it works differently because it must be resolved at compile time. It's much more limited than Ruby's version.
# Ruby: Define methods while program is running
class User
[:name, :email, :age].each do |attr|
define_method(attr) do
instance_variable_get("@#{attr}")
end
end
endCrystal must know all methods at compile time. But don't worry - macros can do similar things (Chapter 09).
# Ruby: Modify core classes at runtime
class String
def shout
upcase + "!!!"
end
end
"hello".shout # => "HELLO!!!"Crystal allows reopening classes, but changes apply to the entire compile. You can't conditionally modify classes at runtime.
The compile-time approach gives you superpowers:
The compiler checks types, so runtime doesn't need to:
def calculate(x : Int32) : Int32
x * 2
end
# Compile time: Compiler verifies x is Int32, return is Int32
# Runtime: Just multiply and return, no type checkingThe compiler removes code that can never run:
def helper
if false
expensive_operation()
end
end
# The compiler sees this is never called and removes it from the binaryCrystal knows exactly which method to call:
class Animal
def speak
"..."
end
end
class Dog < Animal
def speak
"Woof!"
end
end
dog = Dog.new
dog.speak # Compiler knows this is Dog#speak, no lookup needed!In Ruby, this requires a method lookup at runtime. In Crystal, it's often optimized to a direct call.
Small methods can be inlined for performance:
def square(x)
x * x
end
result = square(5)
# The compiler might turn this into:
# result = 5 * 5
# No method call overhead!When the compiler complains, it's trying to help. Let's decode common errors:
def greet(name : String)
"Hello, #{name}"
end
greet(42)Error:
Error: no overload matches 'greet' with type Int32
Translation: "You said greet takes a String, but you gave me an Int32."
arr = []
arr << "hello"Error:
Error: for empty arrays use '[] of ElementType'
Translation: "I don't know what type this array should hold. Tell me explicitly."
Fix:
arr = [] of String
arr << "hello"value = rand > 0.5 ? "hello" : 42
value.upcaseError:
Error: undefined method 'upcase' for Int32
Translation: "value could be Int32, and Int32 doesn't have upcase. Handle both cases."
Fix:
value = rand > 0.5 ? "hello" : 42
if value.is_a?(String)
value.upcase
endCrystal can't do runtime metaprogramming, but it has something arguably more powerful: compile-time metaprogramming through macros.
A macro is code that runs at compile time and generates more code:
macro define_getter(name)
def {{name}}
@{{name}}
end
end
class Person
def initialize(@name : String, @age : Int32)
end
define_getter name
define_getter age
end
person = Person.new("Alice", 30)
puts person.name # => "Alice"
puts person.age # => 30The define_getter macro runs at compile time and generates the name and age methods. By runtime, they're just normal methods.
We'll explore macros deeply in Chapter 09, but for now, know they're Crystal's answer to Ruby's runtime metaprogramming.
Let's see how compile-time decisions enable performance:
# Ruby version: Method lookup at runtime
class Calculator
def process(operation, a, b)
case operation
when :add then a + b
when :subtract then a - b
when :multiply then a * b
when :divide then a / b
end
end
end
calc = Calculator.new
result = calc.process(:add, 5, 3)# Crystal version: Compiler can optimize based on types
class Calculator
def add(a : Int32, b : Int32) : Int32
a + b
end
def subtract(a : Int32, b : Int32) : Int32
a - b
end
def multiply(a : Int32, b : Int32) : Int32
a * b
end
def divide(a : Int32, b : Int32) : Int32
a / b
end
end
calc = Calculator.new
result = calc.add(5, 3) # Direct call, no case statement overheadThe Crystal version is more verbose but the compiler can optimize it aggressively because it knows exactly what's happening.
Think of the Crystal compiler as a very picky but helpful teammate:
# Compiler struggles
def parse(input)
input.to_i? || 0 # Return type is Int32 | Int32, simplified to Int32
end
# Help the compiler
def parse(input : String) : Int32
input.to_i? || 0
end# Define specific behavior for different types
def format(value : Int32) : String
value.to_s
end
def format(value : Float64) : String
"%.2f" % value
end
def format(value : String) : String
"\"#{value}\""
end
puts format(42) # => "42"
puts format(3.14159) # => "3.14"
puts format("hello") # => "\"hello\""The compiler picks the right method at compile time based on the argument type.
# Sometimes union types are the right answer
def get_config(key : String) : String | Int32 | Bool
case key
when "name"
"MyApp"
when "port"
3000
when "debug"
true
else
""
end
end# A simple expression evaluator that the compiler can optimize
class Expression
def self.parse(expr : String) : Int32?
parts = expr.split(' ')
return nil if parts.size != 3
left = parts[0].to_i?
operator = parts[1]
right = parts[2].to_i?
return nil if left.nil? || right.nil?
case operator
when "+" then left + right
when "-" then left - right
when "*" then left * right
when "/" then right != 0 ? left / right : nil
else nil
end
end
end
# At compile time, Crystal:
# - Knows all types
# - Can inline small methods
# - Can optimize the case statement
# - Can remove impossible code paths
result = Expression.parse("5 + 3")
if result
puts "Result: #{result}"
endThe compiler can optimize this heavily because everything is known at compile time.
x = [1, 2, 3]
puts typeof(x) # => Array(Int32)
y = [1, "two", 3.0]
puts typeof(y) # => Array(Int32 | String | Float64)# See what the compiler is doing
crystal build --debug myprogram.cr
# Print type information
crystal tool types myprogram.crCompiler errors include:
- What went wrong
- What types were involved
- Where the error occurred
- Sometimes suggestions for fixes
Don't just glance at errors - read them. The compiler is trying to help.
Compile Time World:
- Types are checked
- Macros expand
- Code is analyzed
- Nothing executes
- All decisions are made
Runtime World:
- Code executes
- Data flows
- Results are computed
- No type checking
- No compiler
Your code must satisfy the compile time world before it can enter the runtime world.
- Write a method that only works with specific types using method overloading
- Try to create code that would work in Ruby but fails to compile in Crystal - understand why
- Use
typeofto explore the types of various expressions - Write a simple macro (preview of Chapter 09) that generates getter methods
- Crystal programs have two phases: compile time and runtime
- Type checking happens at compile time, not runtime
- Many Ruby features (eval, dynamic method definition) don't work because they require runtime compilation
- Compile-time decisions enable significant performance optimizations
- Macros provide compile-time metaprogramming
- The compiler is strict but makes your programs faster and safer
- Understanding compile time vs runtime explains many of Crystal's design decisions
Now that you understand compile time vs runtime, let's tackle one of the most common sources of bugs in any language: nil values. In the next chapter, we'll explore how Crystal's type system makes nil explicit and forces you to handle it properly - saving you from countless NoMethodError surprises.
Continue to Chapter 04 - Nil Safety (No More NoMethodError on nil:NilClass) →