Error Handling¶
Typestates model errors as states, not exceptions. If an operation can fail, the failure is a state the object transitions to.
The Rule¶
All {.transition.} procs must have {.raises: [].} - either explicitly
declared or inferred. The library enforces this at compile time.
Why?¶
Exceptions bypass the state machine. If a transition raises an exception, the caller never receives the promised state. The object's logical state becomes undefined.
Error states are explicit and trackable. The type system knows about them, and callers must handle them.
Example¶
# Wrong: exception bypasses state machine
proc open(f: Closed, path: string): Open {.transition.} =
if not fileExists(path):
raise newException(IOError, "not found") # Compile error!
...
# Right: error is a state, use branch type
# (assumes: Closed -> (Open | OpenFailed) as OpenResult)
proc open(f: Closed, path: string): OpenResult {.transition.} =
if not fileExists(path):
return OpenResult -> OpenFailed(f.File)
OpenResult -> Open(f.File)
Defects vs Exceptions¶
Nim distinguishes between Defects (bugs) and CatchableErrors (recoverable errors).
Defects¶
Programming errors that should not be caught:
IndexDefect- array/seq index out of boundsDivByZeroDefect- division by zeroAssertionDefect- failed assertion
Defects are NOT tracked by the {.raises.} pragma. A proc can have
{.raises: [].} but still trigger a Defect if there's a bug.
CatchableErrors¶
Recoverable errors that callers can handle:
IOError- file/network operationsValueError- parsing, conversionOSError- system calls
These ARE tracked by {.raises.}. Our enforcement prevents transitions
from raising them.
What Typestates Guarantee¶
The library guarantees protocol correctness - you cannot call operations in the wrong state. It does NOT guarantee implementation correctness - your transition body might still have bugs that trigger Defects.
Recommendation: Avoid Defect-prone operations in transitions, or guard them:
# Risky: seq[i] can raise IndexDefect
proc getItem(c: HasItems): Item {.transition, raises: [].} =
result = c.items[0] # Bug if items is empty!
# Safer: check first, return error state
# (assumes: HasItems -> (Item | Empty) as GetItemResult)
proc getItem(c: HasItems): GetItemResult {.transition, raises: [].} =
if c.items.len == 0:
return GetItemResult -> Empty(c.Container)
GetItemResult -> Item(c.items[0])
Patterns¶
Branching Transitions¶
For transitions that can result in multiple states (success or failure),
use the as TypeName syntax to name the branch type. Given:
typestate Connection:
states Disconnected, Connected, ConnectionFailed
transitions:
Disconnected -> (Connected | ConnectionFailed) as ConnectResult
Connected -> Disconnected
ConnectionFailed -> Disconnected
The macro generates branch types and the -> operator for constructing results.
Use the -> operator in your transition:
proc connect(c: Disconnected, host: string): ConnectResult {.transition, raises: [].} =
try:
let socket = connectSocket(host)
var conn = Connected(c.Connection)
conn.Connection.socket = socket
ConnectResult -> conn
except OSError:
ConnectResult -> ConnectionFailed(c.Connection)
The -> operator takes the branch type on the left and the destination state on the right.
Then pattern match on the result:
let result = connect(disconnected, "localhost")
case result.kind
of cConnected:
echo "Connected!"
use(result.connected)
of cConnectionFailed:
echo "Failed to connect"
retry(result.connectionfailed)
Or use the auto-generated match macro for compile-time exhaustiveness:
var result = connect(disconnected, "localhost")
match result:
Connected(c):
echo "Connected!"
use(c)
ConnectionFailed(f):
echo "Failed to connect"
retry(f)
The matched value must be a var binding (branch fields are extracted with
move()), the syntax is StateName(bind): (not of StateName as bind:),
and every branch must be covered — there is no else: escape hatch. See
Pattern matching with match in the DSL
reference for details.
Wrap External Calls¶
Create {.raises: [].} wrappers for exception-throwing APIs:
proc tryReadFile(path: string): Option[string] {.raises: [].} =
try:
result = some(readFile(path))
except IOError:
result = none(string)
# (assumes: Empty -> (Loaded | LoadFailed) as LoadResult)
proc load(f: Empty, path: string): LoadResult {.transition, raises: [].} =
let content = tryReadFile(path)
if content.isNone:
return LoadResult -> LoadFailed(f.Document)
var loaded = Loaded(f.Document)
loaded.Document.content = content.get
LoadResult -> loaded
Result Types¶
Use Result[T, E] for structured error handling:
# (assumes: Empty -> (Loaded | LoadFailed) as LoadResult)
proc load(f: Empty, path: string): LoadResult {.transition, raises: [].} =
let content = readFileResult(path) # returns Result[string, IOError]
if content.isErr:
return LoadResult -> LoadFailed(f.Document)
var loaded = Loaded(f.Document)
loaded.Document.content = content.get
LoadResult -> loaded
Result as a Direct Return Type¶
Transitions can return Result[State, E] directly — the library
unwraps common wrappers (Result, Option, Future) and validates
the underlying state:
import results
# (assumes: Proposed -> PreChecked)
proc preCheck(p: Proposed): Result[PreChecked, GateRejection] {.transition.} =
if rejected:
err(GateRejection.InvalidAmount)
else:
ok(PreChecked(Order(p)))
Use this pattern when there is exactly one success state and the
error channel carries data. Use branch types (... as LoadResult)
when the "failure" is itself a distinct state in the machine.
For async procs, return Future[T] or Future[Result[T, E]] with
{.async, transition.} — the chain unwraps through both wrappers.
See Transparent Wrappers.
State-Aware Error Messages¶
Calling a transition on the wrong source state used to surface Nim's
generic Error: type mismatch diagnostic. Starting in v0.5, every module
that ends with verifyTypestates() automatically emits {.error.} decoy
overloads alongside each non-generic, non-branching {.transition.} proc.
The decoys cover the other states of the same typestate, so misuse fires
a tailored message naming the proc, the wrong state, and the expected
source state.
typestate Door:
states Closed, Open, Locked
transitions:
Closed -> Open
Open -> Locked
proc unlock(d: sink Closed): Open {.transition.} =
Open(Door(d))
proc lock(d: sink Open): Locked {.transition.} =
Locked(Door(d))
verifyTypestates() # required for state-aware errors
let c = Closed(Door())
discard c.lock()
# Error: Cannot call 'lock' on a value in state 'Closed'.
# Expected 'Open'. (Defined at <module>)
The trailing parameters of the original transition are preserved on the
decoy, so proc close(a: sink Active, reason: string) produces decoys
shaped proc close(p: sink Frozen, reason: string), ensuring overload
resolution selects the decoy at the call site even when the user passed
extra arguments.
When decoys are not emitted¶
The v0.5 pass intentionally skips three categories. Misuse in these
configurations still fails to compile, but with the standard type
mismatch message rather than the tailored one. Lifting these
restrictions is tracked as a v0.6 follow-up.
- Generic typestates (
typestate Container[T]:). The decoy codegen needs to thread the typestate's generic parameters through each decoynnkProcDef, which v0.5 does not do. - Branching-return transitions (
Created -> (Approved | Declined) as ProcessResult). A decoy returningautocannot represent the branch shape unambiguously. - Union-source transitions (
proc f(s: Open | PartiallyFilled): ...). A union source already covers multiple states; deriving the uncovered states from a union is deferred.
If verifyTypestates() is omitted from a module, no decoys are emitted
and you fall back to Nim's default error. Keep the call as the last
statement of the module so all transitions and overloads have been
registered before the decoy pass runs.