Understanding Error Handling in Crystal
Crystal draws inspiration from Ruby's syntax but takes a fundamentally different approach to error handling. Rather than relying heavily on exceptions as a control flow mechanism, Crystal encourages a more explicit, type-safe model where errors are treated as values. This design philosophy stems from Crystal's static type system and its goal of catching potential failures at compile time rather than at runtime.
At its core, error handling in Crystal revolves around three primary mechanisms: exceptions for truly exceptional circumstances, nil-returning methods for expected failures, and union types that allow you to build sophisticated error-handling pipelines. Understanding when to use each pattern is essential for writing robust, maintainable Crystal applications.
Why Error Handling Patterns Matter in Crystal
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →In dynamically typed languages like Ruby, you might rely on exceptions extensively and handle them with begin/rescue blocks. Crystal, being statically typed, requires you to be more deliberate. The compiler must be able to infer the types of all expressions, including error states. This means:
- Type safety — The compiler forces you to handle potential nil values and error union types, preventing null reference errors at compile time
- Performance — Avoiding exceptions for expected control flow is significantly faster since raising and rescuing exceptions involves stack trace generation
- Explicitness — Error states become part of a function's type signature, making APIs self-documenting and easier to reason about
- Composability — Error-aware types can be chained and transformed without nested rescue blocks
Exception-Based Error Handling
Raising and Rescuing Exceptions
Crystal provides a familiar raise / rescue mechanism for truly exceptional situations — things that should not happen during normal program execution, such as malformed input that violates fundamental assumptions or system-level failures.
class DivisionByZeroError < Exception
def initialize
super("Cannot divide by zero")
end
end
def safe_divide(numerator : Float64, denominator : Float64) : Float64
if denominator == 0
raise DivisionByZeroError.new
end
numerator / denominator
end
begin
result = safe_divide(10.0, 0.0)
puts "Result: #{result}"
rescue e : DivisionByZeroError
puts "Caught specific error: #{e.message}"
rescue e : Exception
puts "Caught general error: #{e.message}"
ensure
puts "This always runs, regardless of exceptions"
end
Defining Custom Exception Hierarchies
Creating a well-structured exception hierarchy helps you catch errors at the appropriate level of granularity. Crystal allows you to define exception classes that inherit from Exception or any of its subclasses.
class AppError < Exception
getter code : Int32
def initialize(@code : Int32, message : String)
super(message)
end
end
class ValidationError < AppError
getter field : String
def initialize(@field : String, message : String)
super(422, message)
end
end
class NotFoundError < AppError
def initialize(message : String = "Resource not found")
super(404, message)
end
end
def find_user(id : Int32) : String
if id <= 0
raise ValidationError.new("id", "User ID must be positive")
elsif id > 100
raise NotFoundError.new("User with ID #{id} not found")
end
"User#{id}"
end
# Catching by hierarchy level
begin
user = find_user(-5)
rescue e : ValidationError
puts "Validation failed on field '#{e.field}': #{e.message}"
rescue e : AppError
puts "Application error (code #{e.code}): #{e.message}"
rescue e : Exception
puts "Unexpected error: #{e.message}"
end
When to Use Exceptions
Reserve exceptions for circumstances that are truly exceptional and unrecoverable within the current context. Good candidates include:
- Violations of fundamental invariants (e.g., a required configuration file is missing)
- Programming errors that should be surfaced immediately (e.g., an unexpected nil in a non-nilable context)
- System-level failures (e.g., failed memory allocation)
- Contract violations where continuing execution would produce incorrect results
Nil-Based Error Handling
Returning nil to Signal Absence or Failure
A common pattern in Crystal is to return nil when an operation cannot produce a valid result. This is especially useful for lookup operations where "not found" is a legitimate, expected outcome.
def find_by_name(collection : Array(String), name : String) : String?
collection.each do |item|
return item if item == name
end
nil # Explicitly return nil when not found
end
names = ["Alice", "Bob", "Charlie"]
result = find_by_name(names, "Bob")
if result
puts "Found: #{result}"
else
puts "Name not found in collection"
end
# Using the nil-aware shorthand
puts find_by_name(names, "David") || "Default fallback value"
Chaining Nil-able Operations
Crystal's type system tracks nilability throughout method chains. You must handle the nil case explicitly, which the compiler enforces. This prevents the dreaded "No method for nil" errors at runtime.
struct User
property name : String
property email : String?
def initialize(@name : String, @email : String? = nil)
end
end
def fetch_user(id : Int32) : User?
# Simulate a database lookup
if id == 1
User.new("Alice", "alice@example.com")
elsif id == 2
User.new("Bob") # No email provided
else
nil # User not found
end
end
user = fetch_user(2)
# Compiler forces you to handle nil
if user
name = user.name.upcase
# email might also be nil
email_display = user.email ? user.email.not_nil! : "No email provided"
puts "User #{name} — #{email_display}"
else
puts "User does not exist"
end
# Alternative: using try for safe method chaining
display_name = fetch_user(3).try(&.name) || "Anonymous"
puts display_name
The try Method for Safe Navigation
The try method provides a concise way to call methods on potentially nil values. It returns the result wrapped in the receiver's nilable type or nil if the receiver is nil.
class Address
property city : String
def initialize(@city : String)
end
end
class Profile
property address : Address?
def initialize(@address : Address? = nil)
end
end
profile = Profile.new(Address.new("San Francisco"))
profile_without_address = Profile.new
# Safe navigation with try
city1 = profile.try(&.address).try(&.city) # => "San Francisco"
city2 = profile_without_address.try(&.address).try(&.city) # => nil
# Using the shorthand &. notation
city3 = profile.address.try { |addr| addr.city } # Equivalent
puts city1 || "No city"
puts city2 || "No city"
Union Type Error Handling
Encoding Errors in Return Types
One of Crystal's most powerful features is the ability to return union types that explicitly encode both success and error states. This creates a type-driven error handling pattern similar to Result types in functional languages.
# Define specific error types
struct ParseError
property reason : String
property position : Int32
def initialize(@reason : String, @position : Int32)
end
end
struct OverflowError
property max_value : Int32
property actual_value : Int32
def initialize(@max_value : Int32, @actual_value : Int32)
end
end
# Union type alias for clarity
alias ConversionError = ParseError | OverflowError
def parse_int(input : String, max : Int32) : Int32 | ConversionError
# Attempt parsing
begin
value = input.to_i
rescue e : ArgumentError
return ParseError.new("Invalid integer format", 0)
end
if value > max
return OverflowError.new(max, value)
end
value
end
result = parse_int("42", 100)
# Type-based pattern matching with case
case result
when Int32
puts "Parsed successfully: #{result}"
when ParseError
puts "Parse error: #{result.reason} at position #{result.position}"
when OverflowError
puts "Overflow: value #{result.actual_value} exceeds max #{result.max_value}"
end
Building a Generic Result Type
For larger applications, a reusable Result(T, E) type provides a consistent error-handling pattern across your codebase. Crystal's generics make this straightforward and type-safe.
module Result
# Success variant
struct Ok(T)
getter value : T
def initialize(@value : T)
end
def ok? : Bool
true
end
def error? : Bool
false
end
end
# Error variant
struct Err(E)
getter error : E
def initialize(@error : E)
end
def ok? : Bool
false
end
def error? : Bool
true
end
end
end
# Type alias for convenience
alias Result(T, E) = Result::Ok(T) | Result::Err(E)
# Function using the Result type
def validate_email(email : String) : Result(String, String)
if email.includes?("@") && email.includes?(".")
Result::Ok.new(email.downcase)
else
Result::Err.new("Email must contain @ and a domain")
end
end
def validate_age(age : Int32) : Result(Int32, String)
if age >= 0 && age <= 150
Result::Ok.new(age)
else
Result::Err.new("Age must be between 0 and 150")
end
end
# Composing multiple Result operations
email_result = validate_email("user@example.com")
age_result = validate_age(25)
# Pattern match on both results
case {email_result, age_result}
when {Result::Ok, Result::Ok}
email = email_result.value
age = age_result.value
puts "Valid user: #{email}, age #{age}"
when {Result::Ok, Result::Err}
puts "Invalid age: #{age_result.error}"
when {Result::Err, Result::Ok}
puts "Invalid email: #{email_result.error}"
when {Result::Err, Result::Err}
puts "Both invalid: #{email_result.error} and #{age_result.error}"
end
Implementing map and flat_map for Result Chains
To make the Result type truly composable, you can implement functional transformations that propagate errors automatically.
# Extending Result with functional operations
module ResultExtensions
def map(&block : T -> U) : Result(U, E) forall T, E, U
case self
when Result::Ok(T)
Result::Ok.new(block.call(self.value))
when Result::Err(E)
self
end
end
def flat_map(&block : T -> Result(U, E)) : Result(U, E) forall T, E, U
case self
when Result::Ok(T)
block.call(self.value)
when Result::Err(E)
self
end
end
def or_else(&block : E -> T) : T forall T, E
case self
when Result::Ok(T)
self.value
when Result::Err(E)
block.call(self.error)
end
end
end
# Reopen the union types to include extensions
struct Result::Ok(T)
include ResultExtensions
end
struct Result::Err(E)
include ResultExtensions
end
# Usage example with chained transformations
def parse_user_id(input : String) : Result(Int32, String)
if input.empty?
Result::Err.new("Input cannot be empty")
else
begin
Result::Ok.new(input.to_i)
rescue e : ArgumentError
Result::Err.new("Invalid integer: #{input}")
end
end
end
def fetch_user_name(id : Int32) : Result(String, String)
if id == 1
Result::Ok.new("Alice")
elsif id == 2
Result::Ok.new("Bob")
else
Result::Err.new("User not found with ID #{id}")
end
end
# Chaining operations with flat_map
user_name_result = parse_user_id("1")
.flat_map { |id| fetch_user_name(id) }
.map { |name| name.upcase }
# Handling the final result
final_output = user_name_result.or_else { |error| "Error: #{error}" }
puts final_output
# Another chain demonstrating error propagation
failed_result = parse_user_id("invalid")
.flat_map { |id| fetch_user_name(id) }
.map { |name| "Hello, #{name}" }
puts failed_result.or_else { |error| "Failed: #{error}" }
Compile-Time Error Awareness
Leveraging the Compiler's Type Checking
Crystal's compiler actively helps you write error-safe code by tracking nilable types and requiring explicit handling. This is not just a convention — it's enforced at compile time.
# This will produce a compile error
def problematic_function
user = fetch_user(3) # Returns User?
user.name # Compile error: can't call name on Nil
end
# The compiler forces you to handle the nil case
def correct_function
user = fetch_user(3)
if user
user.name # Here the compiler knows user is User, not nil
else
"Unknown"
end
end
# Using not_nil! for cases where you're certain
def assert_found(id : Int32) : String
user = fetch_user(id)
user.not_nil!.name # Raises at runtime if nil, but compiles
end
Exhaustive Case Checking
When working with union types, Crystal's case expression can help you handle every possible variant. The compiler will warn or error if you miss a case.
enum PaymentStatus
Pending
Completed
Failed
Refunded
end
struct PaymentError
property code : Int32
property message : String
def initialize(@code : Int32, @message : String)
end
end
alias PaymentResult = PaymentStatus | PaymentError
def process_payment(amount : Float64) : PaymentResult
if amount <= 0
PaymentError.new(400, "Invalid amount")
elsif amount > 1000
PaymentError.new(402, "Amount exceeds limit")
else
PaymentStatus::Completed
end
end
result = process_payment(500.0)
# Exhaustive case ensures all types are handled
message = case result
when PaymentStatus::Completed
"Payment processed successfully"
when PaymentStatus::Failed
"Payment failed"
when PaymentStatus::Pending
"Payment is pending"
when PaymentStatus::Refunded
"Payment was refunded"
when PaymentError
"Error #{result.code}: #{result.message}"
end
puts message
Error Handling in Concurrent Contexts
Handling Errors in Fibers and Channels
Crystal's concurrency model uses fibers and channels. Error handling must account for the asynchronous nature of these constructs.
channel = Channel(String | Exception).new
# Spawn a fiber that communicates results via channel
spawn do
begin
# Simulate work that might fail
if rand > 0.5
channel.send("Task completed successfully")
else
raise Exception.new("Task failed due to random failure")
end
rescue e : Exception
channel.send(e)
end
end
# Receive and handle the result
result = channel.receive
case result
when String
puts "Success: #{result}"
when Exception
puts "Failure: #{result.message}"
end
Timeout-Based Error Handling
Long-running operations can be wrapped with timeout mechanisms to prevent indefinite blocking.
def fetch_with_timeout(url : String, timeout : Time::Span) : String | TimeoutError
channel = Channel(String | Exception).new
spawn do
begin
# Simulate HTTP request
sleep Time::Span.new(seconds: rand(1..5).to_i64)
channel.send("Response from #{url}")
rescue e : Exception
channel.send(e)
end
end
# Wait for result or timeout
select
when channel.receive?(timeout)
result = channel.receive
case result
when String
result
when Exception
raise result
end
when timeout(timeout)
TimeoutError.new("Request to #{url} timed out after #{timeout}")
end
end
struct TimeoutError
property message : String
def initialize(@message : String)
end
end
result = fetch_with_timeout("https://api.example.com", Time::Span.new(seconds: 2))
case result
when String
puts "Fetched: #{result}"
when TimeoutError
puts "Timeout: #{result.message}"
end
Best Practices for Error Handling in Crystal
Choose the Right Pattern for the Situation
- Use nil for expected absence — When a lookup might legitimately not find a result, returning nil is idiomatic and lightweight
- Use exceptions for truly exceptional cases — Reserve raise/rescue for violations of core assumptions, system failures, and programming errors
- Use union types for domain errors — When errors carry meaningful information that callers need to handle, encode them in the return type
- Use a Result type for complex pipelines — When you need to chain multiple fallible operations, a generic Result type with map/flat_map provides clarity
Make Error States Explicit in Type Signatures
Always annotate method return types to include possible error states. This documentation is enforced by the compiler.
# Good: explicit about possible failure modes
def read_config(path : String) : Hash(String, String) | FileNotFoundError | ParseError
# implementation
end
# Avoid: hiding error states behind exceptions without type-level documentation
# This forces callers to read implementation or rely on rescue blocks
def read_config_hidden(path : String) : Hash(String, String)
# implementation that raises without type-level indication
end
Never Rescue Broadly Without Re-raising
Catching Exception broadly can swallow critical errors like memory errors or signal interruptions. Always rescue specific exception types and consider re-raising unexpected ones.
# Dangerous: swallows all errors silently
begin
perform_critical_operation
rescue e : Exception
# This catches even fatal errors
logger.warn("Something went wrong")
end
# Better: catch specific errors, let others propagate
begin
perform_critical_operation
rescue e : NetworkError | ValidationError
logger.warn("Handled expected error: #{e.message}")
# Handle gracefully
# Other exceptions propagate up to a global handler
end
# Even better: log and re-raise unexpected errors
begin
perform_critical_operation
rescue e : NetworkError
handle_network_error(e)
rescue e : Exception
logger.error("Unexpected error: #{e.message}", e.backtrace)
raise e # Re-raise to avoid silent failures
end
Use Ensure for Resource Cleanup
The ensure block guarantees cleanup regardless of exceptions, making it perfect for closing files, releasing locks, or freeing resources.
def process_file(path : String) : String
file = File.open(path, "r")
begin
contents = file.gets_to_end
# Process contents — might raise
transform(contents)
ensure
file.close # Always executed, even if transform raises
end
end
# Crystal's block-based methods often handle this automatically
def process_file_idiomatic(path : String) : String
File.open(path, "r") do |file|
transform(file.gets_to_end)
end
# File is automatically closed by the block form
end
Create a Consistent Error Taxonomy
For larger applications, define a clear hierarchy of error types that separates different categories of failure.
# Base error types for a web application
class AppError < Exception
getter code : Int32
getter timestamp : Time
def initialize(@code : Int32, message : String)
super(message)
@timestamp = Time.utc
end
end
# Domain errors (expected business logic failures)
class DomainError < AppError
def initialize(message : String)
super(422, message)
end
end
# Infrastructure errors (external dependency failures)
class InfrastructureError < AppError
getter service : String
def initialize(@service : String, message : String)
super(503, message)
end
end
# Security errors
class SecurityError < AppError
def initialize(message : String)
super(403, message)
end
end
# Usage: consistent error handling middleware
def handle_request
begin
yield
rescue e : DomainError
log_warning(e)
render_error(e.code, e.message)
rescue e : InfrastructureError
log_error(e)
render_error(e.code, "Service temporarily unavailable")
rescue e : SecurityError
log_security_event(e)
render_error(e.code, "Access denied")
rescue e : AppError
log_error(e)
render_error(500, "Internal error")
end
end
Test Error Paths Thoroughly
Error handling code is often undertested because it represents the "unhappy path." Make error scenarios a first-class part of your test suite.
# Example using Crystal's built-in testing
require "spec"
describe "User Registration" do
it "returns validation error for empty email" do
result = validate_email("")
result.should be_a(Result::Err)
if result.is_a?(Result::Err)
result.error.should contain("must contain @")
end
end
it "returns validation error for missing domain" do
result = validate_email("user@")
result.should be_a(Result::Err)
end
it "returns ok for valid email" do
result = validate_email("user@example.com")
result.should be_a(Result::Ok)
if result.is_a?(Result::Ok)
result.value.should eq("user@example.com")
end
end
it "raises on nil input" do
expect_raises(Exception) do
validate_email(nil)
end
end
end
Document Error Conditions Clearly
Use Crystal's documentation annotations to communicate error semantics to consumers of your API.
# Well-documented function with error semantics
# Raises an `ArgumentError` if the input string is empty.
# Returns `nil` if the pattern is not found in the string.
# Returns the matched substring on success.
#
# # extract_between("hello world", "hello", "world") # => " "
# extract_between("hello world", "hi", "world") # => nil
# extract_between("", "start", "end") # raises ArgumentError
# def extract_between(source : String, start_delim : String, end_delim : String) : String?
if source.empty?
raise ArgumentError.new("Source string cannot be empty")
end
start_pos = source.index(start_delim)
return nil unless start_pos
end_pos = source.index(end_delim, start_pos + start_delim.size)
return nil unless end_pos
source[start_pos + start_delim.size...end_pos]
end
Conclusion
Error handling in Crystal represents a thoughtful balance between the convenience of exceptions and the safety of explicit error types. By understanding when to use nil for expected absences, exceptions for truly exceptional circumstances, and union types for domain-specific errors, you can write code that is both expressive and safe. The compiler becomes your ally, catching unhandled error states before they reach production. Building a consistent error taxonomy, leveraging functional patterns like map and flat_map, and thoroughly testing error paths will result in Crystal applications that fail gracefully and predictably. The key insight is that errors are not an afterthought — they are an integral part of your type design, shaping APIs that communicate their failure modes clearly and enforce their handling at compile time.