← Back to DevBytes

Error Handling Patterns in Crystal

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles