Errors & Exceptions
Option, or assertion — for each kind of failure.
Exceptions and try
An exception is raised with raise and handled by a try statement with one or more except branches. finally always runs, which is where cleanup belongs.
try / except / finally
import std/strutils
# parseInt raises ValueError on malformed input, so the failure is catchable.
proc parseAmount(text: string): int =
try:
result = parseInt(text.strip()) # may raise
except ValueError as error:
echo "cannot parse '", text, "': ", error.msg
result = 0 # a defined fallback, not a crash
echo parseAmount(" 42 ") # 42
echo parseAmount("forty") # prints the message, then 0
# 'finally' runs on every path, including an unhandled exception.
var handle = "closed"
try:
handle = "open" # acquire
# ... use the resource ...
finally:
handle = "closed" # always release
echo handle # closed
Declaring Your Own Errors
Define exception types for your own failure modes so callers can catch precisely what they expect: inherit from CatchableError and let the hierarchy express how broad the except clause should be.
import std/tables
type
ConfigError = object of CatchableError # a new, catchable error family
MissingKeyError = object of ConfigError # a more specific case
proc required(key: string, config: TableRef[string, string]): string =
if not config.hasKey(key):
raise newException(MissingKeyError, "missing key: " & key)
config[key]
let config = newTable[string, string]({"host": "localhost"})
echo required("host", config) # localhost
try:
discard required("port", config)
except MissingKeyError as error: # catch the specific type first
echo "config incomplete: ", error.msg
except ConfigError: # then the broader family
echo "config error"
Expected Failures Without Exceptions
Not every failure deserves an exception: a missing record, an empty input, an optional setting. Those are ordinary outcomes and read better as values. Reserve exceptions for the unexpected, and assertions for bugs.
Option[T] for Absence
std/options wraps a value that may or may not be there. The caller must test it, so absence cannot be forgotten the way a nil check can.
import std/options
proc findUser(id: int): Option[string] =
if id == 1:
some("ada") # present value
else:
none(string) # typed absence: not an error, just no value
let user = findUser(1)
if user.isSome: # inspect before unwrapping
echo user.get() # ada
echo findUser(2).isNone # true
echo findUser(2).get("anonymous") # anonymous — a default instead of a crash
# Inside a proc that also returns Option, '?' propagates absence automatically:
proc greeting(id: int): Option[string] =
let name = findUser(id)? # returns none(string) early when absent
some("hello " & name)
echo greeting(1) # some("hello ada")
echo greeting(2) # none(string)
Assertions for Programmer Errors
An assertion states something your own code guarantees. If it fails, the bug is in the program, not in the input — so the failure should be loud and immediate.
proc withdraw(balance, amount: int): int =
doAssert amount > 0, "amount must be positive" # caller bug, not user input
doAssert amount <= balance, "insufficient funds" # invariant of this operation
balance - amount
echo withdraw(100, 30) # 70
# withdraw(100, 120) # AssertionDefect — a defect, not a CatchableError
# 'assert' is the optimizable form: the compiler may drop it with --assertions:off.
# 'doAssert' survives every optimization level, so use it for real invariants.
# Both raise a Defect, and in Nim 2 defects are NOT caught by 'except CatchableError'.
Cleanup with defer
defer registers a statement to run when the enclosing scope exits — on the normal path, on return, and on an exception. It replaces most hand-written finally blocks and makes release logic sit next to acquisition.
Registering Cleanup
proc show(path: string) =
let file = open(path) # acquire the resource
defer: file.close() # release it when the proc exits, however it exits
for line in file.lines:
echo line
show("README.md") # the file is closed before the first line prints
# back in the caller — no leak on early return
Order and Exceptions
proc process(payload: string) =
echo "acquire lock"
defer: echo "release lock" # registered first, so it runs last (LIFO)
echo "acquire handle"
defer: echo "release handle" # registered last, so it runs first
if payload.len == 0:
raise newException(ValueError, "empty payload") # both defers still run
echo "process ", payload
try:
process("")
except ValueError as error:
echo "caught: ", error.msg
# Output order: acquire lock, acquire handle, release handle, release lock,
# then caught: empty payload. Defers are LIFO, like a stack of unwinding scopes.
# Note: quit() ends the process immediately and does NOT run pending defers.
Validate at the Boundary
The cleanest error strategy is structural: parse untrusted input once at the edge into a value that is valid by construction, then let the core work without checks. Defects are reserved for logic errors, and exceptions for the few failures the boundary cannot resolve.
import std/strutils, std/options
proc parsePort(raw: string): Option[int] =
## Total function: every input yields a value, nothing raises.
let trimmed = raw.strip()
if trimmed.len == 0 or not trimmed.allCharsInSet(Digits):
return none(int) # malformed input is an expected outcome
let value = parseInt(trimmed)
if value in 1 .. 65535: some(value) else: none(int)
proc startServer(port: int) =
## Inside the core, the range is an invariant: a violation means a bug.
doAssert port in 1 .. 65535, "the boundary guarantees the port range"
echo "listening on ", port
for raw in ["8080", "0", "abc"]:
let port = parsePort(raw)
if port.isSome:
startServer(port.get())
else:
echo "rejected: ", raw