ESC
Type to search...
S
Soli Docs

Error Handling

Try/catch/finally for exception handling, throw for raising errors.

Try / Catch / Finally

try / catch / finally

Handles exceptions with try/catch blocks, using end-delimited syntax just like if, while, and for. Ruby-style aliases are supported: begin for try, rescue for catch, and ensure for finally. They are interchangeable with the canonical keywords; soli fmt normalizes them to try/catch/finally.

# Basic try/catch
try
  result = 10 / 0
catch e
  print("Error: " + str(e))
end

# With finally (always runs)
try
  data = read_file("config.sl")
  print(data)
catch e
  print("Failed: " + str(e))
finally
  print("Cleanup done")
end

# Try/finally without catch
try
  process_data()
finally
  close_connection()
end

# Ruby-style aliases: `begin` for `try`, `rescue` for `catch`, `ensure` for `finally`
begin
  risky_operation()
rescue e
  print("Error: " + str(e))
ensure
  print("Cleanup done")
end

# The postfix `rescue` modifier still works inline, even inside a begin body
begin
  value = (10 / 0) rescue 99   # value becomes 99
rescue e
  value = -1
end

What finally Guarantees

finally runs on every way out of the try, not only the one where the block reaches its end — that is the whole reason to write one.

How the try is leftDoes finally run?
the block finishes normallyyes
the try block returnsyes, then the return proceeds
a catch clause returnsyes, then the return proceeds
the try throws and a catch handles ityes
the try throws and nothing catches ityes, then the exception keeps propagating

So the release always happens, however the function leaves:

def with_connection() -> Hash
  let conn = open_connection()
  try
    return conn.query("...")   # finally still runs before this returns
  finally
    conn.close()
  end
end

A return or throw inside the finally block itself takes over from whatever was in progress, including discarding an exception that was propagating. This matches Ruby's ensure, and it is worth avoiding for that reason — a return in a finally silently swallows errors.

def swallows() -> String
  try
    throw "the error is lost"
  finally
    return "this wins"   # the throw is discarded
  end
end

What catch Receives

throw carries a value, not a message, and that value reaches catch intact no matter how many function calls it crossed on the way — including a throw from inside a callback given to map, filter, each, reduce, sort_by or times. Structured errors work as written:

def find_user(id: Int) -> Hash
  throw {"code": 404, "message": "no such user"} if id < 1
  return {"id": id}
end

try
  find_user(0)
catch e
  print(e["code"])     # 404 — a hash, not the text of one
  print(e["message"])  # no such user
end

Any value can be thrown — a hash, an array, an int, a class instance — and it is the same value when caught. The one exception is an error Soli itself raised (a division by zero, an index out of bounds, a failed Model.find): those have no user-supplied value, so catch binds their message as a string. Check e.class to tell the two apart:

try
  might_fail()
catch e
  if e.class == "hash"
    print("app error #{e["code"]}")
  else
    print("runtime error: #{e}")
  end
end

Throwing Exceptions

throw

Raises an exception that can be caught by a surrounding try/catch block.

def divide(a: Int, b: Int) -> Int
  if b == 0
    throw "Division by zero"
  end
  a / b
end

try
  result = divide(10, 0)
catch e
  print("Caught: " + str(e))  # "Caught: Division by zero"
end

End Syntax

Try/catch supports end-delimited blocks:

try
  result = risky_operation()
catch e
  print("Error: " + str(e))
finally
  cleanup()
end

Typed Catch

catch ClassName e

Catch specific error types by class name. Multiple catch blocks are tried in order. A bare catch without a type acts as a catch-all.

# Define error classes
class NotFoundError
  message: String
  new(msg: String)
    this.message = msg
  end
end

class ValidationError
  message: String
  new(msg: String)
    this.message = msg
  end
end

# Multiple typed catch blocks
try
  throw new NotFoundError("User not found")
catch NotFoundError e
  print("404: " + e.message)
catch ValidationError e
  print("Invalid: " + e.message)
catch e
  print("Unknown error: " + str(e))
end
Catching by Superclass

Typed catches walk the inheritance chain. A catch BaseError will also catch any subclass of BaseError.

class AppError
  message: String
  new(msg: String)
    this.message = msg
  end
end

class NotFoundError < AppError
  new(msg: String)
    super(msg)
  end
end

class PermissionError < AppError
  new(msg: String)
    super(msg)
  end
end

# Catches NotFoundError because it extends AppError
try
  throw new NotFoundError("Page not found")
catch AppError e
  print("App error: " + e.message)
end
Unmatched Exceptions

If no typed catch matches, the exception is re-thrown to the next outer try/catch. Add a bare catch at the end to handle all remaining exceptions.

class ErrorA {}
class ErrorB {}

try
  try
    throw new ErrorB()
  catch ErrorA e
    # Does NOT match ErrorB
    print("A")
  end
catch e
  # ErrorB bubbles up here
  print("Caught in outer: " + str(e))
end

Typed Catch Rules

  • Typed catches only match class instances (strings, ints, etc. will not match)
  • Catches are tried in order — put more specific types first
  • A bare catch e catches everything (including non-instance values)
  • Subclasses match their parent class catches (walks the inheritance chain)
  • Works with both end and {} syntax

Nested Try/Catch

Try/catch blocks can be nested for fine-grained error handling:

try
  print("Outer try")
  try
    throw "inner error"
  catch e
    print("Inner catch: " + str(e))
  end
  print("After inner try")
catch e
  print("Outer catch: " + str(e))
end

Postfix Rescue

expr rescue fallback

Returns fallback if expr throws an exception. A concise alternative to try/catch for simple cases.

# Simple rescue
data = fetch_data() rescue null;
print(data);  # null if fetch_data threw

# With fallback value
config = load_config() rescue {"host": "localhost", "port": 8080};

# Chaining with nullish coalescing
result = risky_operation() rescue null ?? "default";

# In pipelines
data = user_id |> fetch_user |> validate_user rescue null;
Precedence

Rescue has the same precedence as assignment, so x = y rescue z parses as x = (y rescue z).

# These are equivalent
x = risky() rescue "default";
x = (risky() rescue "default");

# Can chain with other operators
a = throw "err" rescue "x" or "y";  # ("x" or "y") is the fallback

When to Use Postfix Rescue

  • Use for simple fallbacks where you just need a default value
  • Use try/catch when you need to handle the error, run cleanup code, or make decisions based on the error type
  • Rescue swallows the error - if you need to log or re-throw, use try/catch instead