Error Handling Patterns: Ruby to Elixir Migration
This document provides guidance on translating Ruby's exception-based error handling to Elixir's conventional {:ok, result} | {:error, reason} pattern. It includes concrete examples and best practices to follow during the migration process.
Fundamental Differences
Ruby's Approach
Ruby primarily uses exceptions for error handling, with a try/catch (begin/rescue) mechanism:
def process_file(path)
begin
content = File.read(path)
# Process content
return content
rescue Errno::ENOENT
puts "File not found: #{path}"
return nil
rescue => e
puts "Error processing file: #{e.message}"
return nil
end
end
Elixir's Approach
Elixir uses return values for expected error conditions and exceptions only for exceptional cases:
def process_file(path) do
case File.read(path) do
{:ok, content} ->
# Process content
{:ok, content}
{:error, :enoent} ->
Logger.warn("File not found: #{path}")
{:error, :file_not_found}
{:error, reason} ->
Logger.error("Error processing file: #{inspect(reason)}")
{:error, :processing_failed}
end
end
Core Pattern Translations
1. Basic Error Handling
Ruby:
def validate_metadata(metadata)
if metadata.nil?
raise ArgumentError, "Metadata cannot be nil"
end
if metadata[:title].nil? || metadata[:title].empty?
raise ValidationError, "Title is required"
end
# Valid metadata
metadata
end
Elixir:
def validate_metadata(metadata) do
cond do
is_nil(metadata) ->
{:error, :metadata_required}
is_nil(metadata[:title]) || metadata[:title] == "" ->
{:error, :title_required}
true ->
{:ok, metadata}
end
end
2. Error Propagation
Ruby:
def process_book(book_path)
begin
metadata = load_metadata(book_path)
content = load_content(book_path)
process_content(content, metadata)
rescue MetadataError => e
puts "Metadata error: #{e.message}"
nil
rescue ContentError => e
puts "Content error: #{e.message}"
nil
rescue => e
puts "Unknown error: #{e.message}"
nil
end
end
def load_metadata(path)
# Can raise MetadataError
end
def load_content(path)
# Can raise ContentError
end
Elixir:
def process_book(book_path) do
with {:ok, metadata} <- load_metadata(book_path),
{:ok, content} <- load_content(book_path),
{:ok, result} <- process_content(content, metadata) do
{:ok, result}
else
{:error, :metadata_invalid} ->
Logger.warn("Metadata error for book: #{book_path}")
{:error, :metadata_invalid}
{:error, :content_invalid} ->
Logger.warn("Content error for book: #{book_path}")
{:error, :content_invalid}
{:error, reason} ->
Logger.error("Unknown error: #{inspect(reason)}")
{:error, :processing_failed}
end
end
def load_metadata(path) do
# Returns {:ok, metadata} or {:error, reason}
end
def load_content(path) do
# Returns {:ok, content} or {:error, reason}
end
3. Custom Error Types
Ruby:
class ValidationError < StandardError; end
class ConversionError < StandardError; end
class ResourceError < StandardError; end
def convert_format(content, format)
case format
when :pdf
begin
convert_to_pdf(content)
rescue => e
raise ConversionError, "PDF conversion failed: #{e.message}"
end
when :epub
begin
convert_to_epub(content)
rescue => e
raise ConversionError, "EPUB conversion failed: #{e.message}"
end
else
raise ValidationError, "Unsupported format: #{format}"
end
end
Elixir:
def convert_format(content, format) do
case format do
:pdf ->
case convert_to_pdf(content) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, {:pdf_conversion, reason}}
end
:epub ->
case convert_to_epub(content) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, {:epub_conversion, reason}}
end
_ ->
{:error, {:unsupported_format, format}}
end
end
Advanced Pattern Translations
1. Context-Rich Errors
Ruby:
def build_ebook(manuscript, options = {})
begin
validate_manuscript!(manuscript)
validate_options!(options)
format = options[:format] || :pdf
result = convert_manuscript(manuscript, format)
if options[:validate]
validate_result!(result, format)
end
result
rescue ValidationError => e
error_context = {
manuscript: manuscript.path,
format: format,
stage: :validation
}
log_error(e, error_context)
raise
rescue ConversionError => e
error_context = {
manuscript: manuscript.path,
format: format,
stage: :conversion
}
log_error(e, error_context)
raise
end
end
Elixir:
def build_ebook(manuscript, options \\ %{}) do
with {:ok, _} <- validate_manuscript(manuscript),
{:ok, validated_options} <- validate_options(options),
format = Map.get(validated_options, :format, :pdf),
{:ok, result} <- convert_manuscript(manuscript, format),
{:ok, validated_result} <- maybe_validate_result(result, format, validated_options) do
{:ok, validated_result}
else
{:error, {:validation, reason}} ->
error_context = %{
manuscript: manuscript.path,
stage: :validation
}
Logger.error("Validation error: #{inspect(reason)}", error_context)
{:error, {:validation_failed, reason}}
{:error, {:conversion, reason}} ->
error_context = %{
manuscript: manuscript.path,
format: Map.get(options, :format, :pdf),
stage: :conversion
}
Logger.error("Conversion error: #{inspect(reason)}", error_context)
{:error, {:conversion_failed, reason}}
{:error, reason} ->
Logger.error("Unknown error: #{inspect(reason)}")
{:error, {:build_failed, reason}}
end
end
defp maybe_validate_result(result, format, options) do
if Map.get(options, :validate, false) do
validate_result(result, format)
else
{:ok, result}
end
end
2. Error Recovery
Ruby:
def process_chapters(book)
results = []
errors = []
book.chapters.each do |chapter|
begin
result = process_chapter(chapter)
results << result
rescue => e
errors << {chapter: chapter.title, error: e.message}
# Continue processing other chapters
end
end
if errors.any?
puts "Completed with #{errors.length} errors:"
errors.each do |error|
puts " - #{error[:chapter]}: #{error[:error]}"
end
end
{results: results, errors: errors}
end
Elixir:
def process_chapters(book) do
{results, errors} =
Enum.reduce(book.chapters, {[], []}, fn chapter, {results, errors} ->
case process_chapter(chapter) do
{:ok, result} ->
{[result | results], errors}
{:error, reason} ->
error_entry = %{chapter: chapter.title, error: reason}
{results, [error_entry | errors]}
end
end)
results = Enum.reverse(results)
errors = Enum.reverse(errors)
if length(errors) > 0 do
Logger.warn("Completed with #{length(errors)} errors:")
Enum.each(errors, fn error ->
Logger.warn(" - #{error.chapter}: #{inspect(error.error)}")
end)
end
{:ok, %{results: results, errors: errors}}
end
3. Using with for Multiple Operations
Ruby:
def generate_formats(manuscript)
begin
pdf_result = generate_pdf(manuscript)
epub_result = generate_epub(manuscript)
mobi_result = generate_mobi(manuscript)
{
pdf: pdf_result,
epub: epub_result,
mobi: mobi_result
}
rescue PDFGenerationError => e
puts "PDF generation failed: #{e.message}"
nil
rescue EPUBGenerationError => e
puts "EPUB generation failed: #{e.message}"
nil
rescue MOBIGenerationError => e
puts "MOBI generation failed: #{e.message}"
nil
end
end
Elixir:
def generate_formats(manuscript) do
with {:ok, pdf_result} <- generate_pdf(manuscript),
{:ok, epub_result} <- generate_epub(manuscript),
{:ok, mobi_result} <- generate_mobi(manuscript) do
{:ok, %{
pdf: pdf_result,
epub: epub_result,
mobi: mobi_result
}}
else
{:error, {:pdf, reason}} ->
Logger.error("PDF generation failed: #{inspect(reason)}")
{:error, {:pdf_generation_failed, reason}}
{:error, {:epub, reason}} ->
Logger.error("EPUB generation failed: #{inspect(reason)}")
{:error, {:epub_generation_failed, reason}}
{:error, {:mobi, reason}} ->
Logger.error("MOBI generation failed: #{inspect(reason)}")
{:error, {:mobi_generation_failed, reason}}
end
end
Best Practices for Elixir Error Handling
When to Use Exceptions vs. Return Values
Use Return Values For:
- Expected error conditions (file not found, validation failures)
- Alternative paths in business logic
- Recoverable errors
- API boundaries
def find_book(id) do
case BookRepo.get(id) do
nil -> {:error, :not_found}
book -> {:ok, book}
end
end
Use Exceptions For:
- Programming errors (bugs)
- Truly exceptional conditions
- Unrecoverable situations
- Internal library functions
def parse_config!(path) do
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, config} -> config
{:error, reason} ->
raise "Invalid configuration: #{inspect(reason)}"
end
{:error, reason} ->
raise "Could not read config: #{inspect(reason)}"
end
end
Error Message Formatting
Ruby:
raise ValidationError, "Invalid title: '#{title}' (must be 1-100 characters)"
Elixir:
{:error, {:invalid_title, title, "must be 1-100 characters"}}
Creating Custom Error Types
Ruby:
module Errors
class ValidationError < StandardError; end
class ConversionError < StandardError; end
class ResourceError < StandardError; end
end
Elixir:
# Using atoms and tuples
{:error, :validation_error}
{:error, {:validation_error, :title, "Title is required"}}
# For more complex cases, using structs
defmodule Codexis.Error do
defexception [:type, :reason, :context]
@type t :: %__MODULE__{
type: atom(),
reason: any(),
context: map()
}
def message(%__MODULE__{type: type, reason: reason, context: context}) do
"Error [#{type}]: #{inspect(reason)}, Context: #{inspect(context)}"
end
end
# Usage
{:error, %Codexis.Error{
type: :validation,
reason: :title_required,
context: %{manuscript_id: 123}
}}
Error Recovery Patterns
Fallback Values:
def get_title(book) do
case Map.fetch(book, :title) do
{:ok, title} -> title
:error -> "Untitled"
end
end
Default Values with ||:
def get_format_option(options) do
case options[:format] do
nil -> {:ok, :pdf} # Default value
format when format in [:pdf, :epub, :mobi] -> {:ok, format}
invalid -> {:error, {:invalid_format, invalid}}
end
end
Maybe Pattern:
def maybe_validate(document, options) do
if Map.get(options, :validate, true) do
validate_document(document)
else
{:ok, document}
end
end
Integration with Logging
Ruby:
def process_manuscript(manuscript)
begin
logger.info("Processing manuscript: #{manuscript.title}")
# Processing logic
logger.info("Successfully processed manuscript")
result
rescue => e
logger.error("Failed to process manuscript: #{e.message}")
logger.debug(e.backtrace.join("\n"))
raise
end
end
Elixir:
def process_manuscript(manuscript) do
Logger.info("Processing manuscript: #{manuscript.title}")
case do_process_manuscript(manuscript) do
{:ok, result} ->
Logger.info("Successfully processed manuscript")
{:ok, result}
{:error, reason} ->
Logger.error("Failed to process manuscript: #{inspect(reason)}")
{:error, reason}
end
end
Parallel Error Handling
Ruby:
def process_in_parallel(items)
results = Parallel.map(items, in_threads: 4) do |item|
begin
process_item(item)
rescue => e
{error: e.message, item: item}
end
end
successes = results.reject { |r| r.is_a?(Hash) && r.key?(:error) }
errors = results.select { |r| r.is_a?(Hash) && r.key?(:error) }
{successes: successes, errors: errors}
end
Elixir:
def process_in_parallel(items) do
results =
Task.async_stream(items, fn item ->
case process_item(item) do
{:ok, result} -> {:success, result}
{:error, reason} -> {:error, {reason, item}}
end
end, timeout: 30_000)
|> Enum.to_list()
|> Enum.map(fn {:ok, result} -> result end)
successes =
results
|> Enum.filter(fn
{:success, _} -> true
_ -> false
end)
|> Enum.map(fn {:success, result} -> result end)
errors =
results
|> Enum.filter(fn
{:error, _} -> true
_ -> false
end)
|> Enum.map(fn {:error, details} -> details end)
{:ok, %{successes: successes, errors: errors}}
end
Conclusion
Migrating from Ruby's exception-based error handling to Elixir's {:ok, result} | {:error, reason} pattern requires a shift in thinking, but results in more explicit and predictable error handling. The key differences to remember:
- In Ruby, exceptions control the flow; in Elixir, return values control the flow
- Ruby uses
begin/rescueblocks; Elixir uses pattern matching withcaseandwith - Ruby raises exceptions for many conditions; Elixir reserves exceptions for truly exceptional cases
- Ruby often returns
nilfor errors; Elixir returns structured{:error, reason}tuples
By following these patterns, your Elixir code will be more idiomatic and take better advantage of Elixir's strengths in error handling and recovery.