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 left | Does finally run? |
|---|---|
| the block finishes normally | yes |
the try block returns | yes, then the return proceeds |
a catch clause returns | yes, then the return proceeds |
the try throws and a catch handles it | yes |
| the try throws and nothing catches it | yes, 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 ecatches everything (including non-instance values) - Subclasses match their parent class catches (walks the inheritance chain)
- Works with both
endand{}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/catchwhen 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