Errors & Exceptions

Nim treats exceptions as ordinary values: they are objects, they can be declared, raised and inspected, and their type forms a hierarchy you can catch at the level of detail that makes sense. The skill is choosing the right tool — exception, 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