DSL Reference¶
Complete reference for the nim-typestates DSL syntax.
Typestate Block¶
States Declaration¶
List all state types that participate in this typestate:
Or use multiline format for readability:
Each state must be a distinct type of the base type:
Transitions Block¶
Declare valid state transitions using -> syntax:
Transition Syntax¶
Simple Transitions¶
One source state to one destination:
Branching Transitions¶
One source state to multiple possible destinations using |, with a required result type name:
The as TypeName syntax names the generated branch type. This is required for all branching transitions.
This means a proc taking Closed can return either Open or Errored, wrapped in an OpenResult variant type.
Wildcard Transitions¶
Any state can transition to a destination using *:
Wildcards are useful for "reset" or "cleanup" operations that work from any state.
Initial and Terminal States¶
Declare entry and exit points for your typestate:
Initial States¶
States that can only be constructed, not transitioned TO:
Or multiple:
Attempting to transition TO an initial state produces a compile-time error:
Error: Cannot transition TO initial state 'Disconnected'.
Initial states can only be constructed, not transitioned to.
Terminal States¶
States that cannot transition FROM (end states):
Or multiple:
Attempting to transition FROM a terminal state produces a compile-time error:
Error: Cannot transition FROM terminal state 'Closed'.
Terminal states are end states with no outgoing transitions.
Complete Example¶
typestate Connection:
states Disconnected, Connected, Closed
initial: Disconnected
terminal: Closed
transitions:
Disconnected -> Connected
Connected -> Closed
Flags¶
Configuration options for the typestate. Flags can appear anywhere in the block.
strictTransitions¶
When true (default), all procs that take a state type must be marked with either {.transition.} or {.notATransition.}.
typestate File:
strictTransitions = false # Allow unmarked procs
states Closed, Open
transitions:
Closed -> Open
consumeOnTransition¶
When true (default), generates =copy hooks that prevent copying state values. This enforces ownership semantics - each state value can only be used once.
typestate File:
consumeOnTransition = false # Allow copying states
states Closed, Open
transitions:
Closed -> Open
When consumeOnTransition = true (default):
State values must be consumed, not copied. Use these patterns:
-
Chain operations directly (preferred):
-
Use
move()for branch types: -
Use
sinkparameters in your transition procs (already generated by the macro).
Common errors with consumeOnTransition = true:
'=copy' is not available for type <State>: You're trying to copy a state value. Solutions:- Use
move()to explicitly consume the value - Chain operations without intermediate variables
-
If you need to reuse values, set
consumeOnTransition = false(see below) -
expression is immutable, not 'var': Usingmove()on aletbinding. Change tovar.
When to use consumeOnTransition = false:
Set consumeOnTransition = false when:
- Value wrappers: Validated values that are stored and reused multiple times (e.g., validated integers, range-checked values, parsed configurations)
- Shared state: States that need to be read by multiple consumers
- Debugging: Temporarily disable to simplify debugging, then re-enable
Implications of consumeOnTransition = false:
- States can be copied freely (no ownership enforcement)
- A state value can be used multiple times
- No compile-time protection against "use after transition"
- Suitable for data validation, not operation sequencing
Recommendation: Use consumeOnTransition = true (default) for operation-based typestates that model workflows, protocols, or resource lifecycles. Use consumeOnTransition = false for value-validation typestates.
Cross-module bridging: When state values are passed between typestates in different modules, use consumeOnTransition = false on both typestates. See Bridges: Cross-Module Considerations.
inheritsFromRootObj¶
Set to true if your base type inherits from RootObj. This suppresses a compile-time error for static generic typestates on Nim < 2.2.8.
type
Buffer[N: static int] = object of RootObj # Note: inherits from RootObj
data: array[N, byte]
Empty[N: static int] = distinct Buffer[N]
Full[N: static int] = distinct Buffer[N]
typestate Buffer[N: static int]:
inheritsFromRootObj = true # Required for static generics with RootObj on Nim < 2.2.8
states Empty[N], Full[N]
transitions:
Empty[N] -> Full[N]
See the Nim codegen bug for details.
Bridges¶
Cross-typestate transitions declared with dotted notation.
Syntax¶
bridges:
SourceState -> DestTypestate.DestState
SourceState -> module.DestTypestate.DestState # Module-qualified
SourceState -> (DestTypestate.State1 | DestTypestate.State2) # Branching
* -> DestTypestate.DestState # Wildcard
Requirements¶
- Destination typestate must be imported
- Destination typestate and state must exist
- Bridge must be declared before implementation
Examples¶
Simple bridge:
Module-qualified bridge:
Branching bridge:
Wildcard bridge:
See Bridges for full documentation.
Pragmas¶
{.transition.}¶
Mark a proc as a state transition. The compiler validates that the transition is declared.
Validation rules:
- First parameter must be a registered state type
- Return type must be a valid transition target
- Transition must be declared in the typestate block
- Automatically gets
{.raises: [].}added if not specified - errors should be states, not exceptions - If you explicitly write
{.raises: [SomeError].}, compilation will fail
See Error Handling for patterns on modeling errors as states.
Error on invalid transition:
Error: Undeclared transition: Open -> Locked
Typestate 'File' does not declare this transition.
Valid transitions from 'Open': @["Closed"]
Hint: Add 'Open -> Locked' to the transitions block.
{.notATransition.}¶
Mark a proc as intentionally NOT a transition. Use for procs that operate on state types but don't change state:
proc write(f: Open, data: string) {.notATransition.} =
# Writes data but stays in Open state
rawWrite(f.File.handle, data)
proc read(f: Open, count: int): string {.notATransition.} =
# Reads data but stays in Open state
result = rawRead(f.File.handle, count)
For pure functions (no side effects), use func instead - no pragma needed:
Generated Types¶
For typestate File: with states Closed, Open, Errored:
State Enum¶
Enum values are prefixed with fs (for "file state") to avoid name collisions.
Union Type¶
Useful for generic procs that accept any state:
proc describe[S: FileStates](f: S): string =
case f.state
of fsClosed: "closed"
of fsOpen: "open"
of fsErrored: "errored"
State Procs¶
proc state*(f: Closed): FileState = fsClosed
proc state*(f: Open): FileState = fsOpen
proc state*(f: Errored): FileState = fsErrored
Branch Types¶
For branching transitions like Created -> (Approved | Declined | Review) as ProcessResult, the macro generates types and helpers for returning multiple possible states.
Usage with the -> operator:
proc process(c: Created): ProcessResult {.transition.} =
if c.Payment.amount > 100:
ProcessResult -> Approved(c.Payment)
elif c.Payment.amount > 50:
ProcessResult -> Review(c.Payment)
else:
ProcessResult -> Declined(c.Payment)
The -> operator takes the branch type on the left and the destination state on the right. This mirrors the DSL syntax and is unambiguous even when the same state appears in multiple branch types.
Pattern matching on the result:
let result = process(created)
case result.kind
of pApproved: echo "Approved: ", result.approved.Payment.amount
of pDeclined: echo "Declined"
of pReview: echo "Needs review"
What gets generated:
The -> operator is syntactic sugar around constructor procs. For each branching transition with as TypeName, the macro generates:
-
Enum -
ProcessResultKind = enum pApproved, pDeclined, pReview(prefix is first letter of type name, e.g.,pfor ProcessResult) -
Variant object -
ProcessResultholding the result -
Constructor procs -
toProcessResult(s: Approved): ProcessResultetc. -
->operator -template ->(T: typedesc[ProcessResult], s: Approved)etc.
You can use the constructors directly if preferred:
See Returning Union Types for more examples.
Complete Example¶
import typestates
type
Connection = object
host: string
port: int
socket: int
Disconnected = distinct Connection
Connecting = distinct Connection
Connected = distinct Connection
Errored = distinct Connection
typestate Connection:
states Disconnected, Connecting, Connected, Errored
transitions:
Disconnected -> Connecting
Connecting -> (Connected | Errored) as ConnectResult
Connected -> Disconnected
Errored -> Disconnected
* -> Disconnected # Can always disconnect
proc connect(c: Disconnected, host: string, port: int): Connecting {.transition.} =
var conn = c.Connection
conn.host = host
conn.port = port
result = Connecting(conn)
proc waitForConnection(c: Connecting): ConnectResult {.transition.} =
# In real code, this would do async I/O
if true: # Pretend success
ConnectResult -> Connected(c.Connection)
else:
ConnectResult -> Errored(c.Connection)
# Wildcard transitions require separate procs for each source state.
# The {.transition.} pragma validates each at compile time.
proc disconnect(c: Disconnected): Disconnected {.transition.} =
result = c # Already disconnected
proc disconnect(c: Connecting): Disconnected {.transition.} =
var conn = c.Connection
conn.socket = 0
result = Disconnected(conn)
proc disconnect(c: Connected): Disconnected {.transition.} =
var conn = c.Connection
conn.socket = 0
result = Disconnected(conn)
proc disconnect(c: Errored): Disconnected {.transition.} =
var conn = c.Connection
conn.socket = 0
result = Disconnected(conn)
proc send(c: Connected, data: string) {.notATransition.} =
# Send data, stay connected
discard
Tips¶
Accessing the Base Type¶
State types are distinct, so you need to convert to access fields:
Returning Union Types¶
For branching transitions, use the -> operator with the generated branch type:
# Branching transition: Connecting -> (Connected | Errored) as ConnectResult
proc waitForConnection(c: Connecting): ConnectResult {.transition.} =
if success:
ConnectResult -> Connected(c.Connection)
else:
ConnectResult -> Errored(c.Connection)
Then pattern match on the result:
let result = conn.waitForConnection()
case result.kind
of cConnected:
echo "Connected!"
sendData(result.connected)
of cErrored:
echo "Error: ", result.errored.message
Why branch types? Nim's A | B syntax creates a generic type constraint, not a runtime sum type. You cannot actually return different types from if/else branches. The generated branch types solve this by wrapping the result in an object variant.
Generic Over All States¶
Use the generated union type for generic procs: