Skip to content

DSL Reference

Complete reference for the nim-typestates DSL syntax.

Typestate Block

typestate TypeName:
  states State1, State2, State3
  transitions:
    State1 -> State2
    State2 -> State3

States Declaration

List all state types that participate in this typestate:

states Closed, Open, Reading, Writing, Errored

Or use multiline format for readability:

states:
  Closed
  Open
  Reading
  Writing
  Errored

Each state must be a distinct type of the base type:

type
  File = object
    # ...
  Closed = distinct File
  Open = distinct File

Transitions Block

Declare valid state transitions using -> syntax:

transitions:
  Closed -> Open
  Open -> Closed

Transition Syntax

Simple Transitions

One source state to one destination:

Closed -> Open

Branching Transitions

One source state to multiple possible destinations using |, with a required result type name:

Closed -> (Open | Errored) as OpenResult

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 *:

* -> Closed

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:

initial: Disconnected

Or multiple:

initial: Disconnected, Starting

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):

terminal: Closed

Or multiple:

terminal: Closed, Failed

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:

  1. Chain operations directly (preferred):

    # Good: Chain without intermediate variables
    return file.open().write(data).close()
    

  2. Use move() for branch types:

    var result = file.tryOpen()
    case result.kind:
    of fsOpened:
      return move(result).opened.write(data)  # move() consumes the variant
    of fsFailed:
      return move(result).failed.getError()
    

  3. Use sink parameters 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': Using move() on a let binding. Change to var.

When to use consumeOnTransition = false:

Set consumeOnTransition = false when:

  1. Value wrappers: Validated values that are stored and reused multiple times (e.g., validated integers, range-checked values, parsed configurations)
  2. Shared state: States that need to be read by multiple consumers
  3. 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:

bridges:
  Authenticated -> Session.Active

Module-qualified bridge:

bridges:
  Authenticated -> session_module.Session.Active

Branching bridge:

bridges:
  Authenticated -> (Session.Active | Session.Guest)

Wildcard bridge:

bridges:
  * -> Shutdown.Terminal

See Bridges for full documentation.

Pragmas

{.transition.}

Mark a proc as a state transition. The compiler validates that the transition is declared.

proc open(f: Closed): Open {.transition.} =
  result = Open(f)

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:

func path(f: Open): string =
  f.File.path

Generated Types

For typestate File: with states Closed, Open, Errored:

State Enum

type FileState* = enum
  fsClosed, fsOpen, fsErrored

Enum values are prefixed with fs (for "file state") to avoid name collisions.

Union Type

type FileStates* = Closed | Open | Errored

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:

  1. Enum - ProcessResultKind = enum pApproved, pDeclined, pReview (prefix is first letter of type name, e.g., p for ProcessResult)

  2. Variant object - ProcessResult holding the result

  3. Constructor procs - toProcessResult(s: Approved): ProcessResult etc.

  4. -> operator - template ->(T: typedesc[ProcessResult], s: Approved) etc.

You can use the constructors directly if preferred:

toProcessResult(Approved(c.Payment))  # Equivalent to: ProcessResult -> Approved(c.Payment)

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:

proc path(f: Open): string =
  f.File.path  # Convert Open to File to access .path

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:

proc forceClose[S: FileStates](f: S): Closed =
  Closed(f.File)