Generic Typestates¶
nim-typestates supports generic type parameters, enabling reusable typestate patterns.
Basic Generic Typestate¶
Define a typestate with type parameters:
import typestates
type
Container[T] = object
value: T
Empty[T] = distinct Container[T]
Full[T] = distinct Container[T]
typestate Container[T]:
states Empty[T], Full[T]
transitions:
Empty[T] -> Full[T]
Full[T] -> Empty[T]
Implement transitions using generic procs:
proc fill[T](c: Empty[T], val: T): Full[T] {.transition.} =
var cont = Container[T](c)
cont.value = val
result = Full[T](cont)
proc empty[T](c: Full[T]): Empty[T] {.transition.} =
result = Empty[T](Container[T](c))
Use with any type:
# With int
let e = Empty[int](Container[int](value: 0))
let f = e.fill(42)
let e2 = f.empty()
# With string
let s = Empty[string](Container[string](value: ""))
let s2 = s.fill("hello")
Multiple Type Parameters¶
Typestates can have multiple type parameters:
type
KeyValue[K, V] = object
key: K
value: V
EmptyKV[K, V] = distinct KeyValue[K, V]
HasKey[K, V] = distinct KeyValue[K, V]
HasBoth[K, V] = distinct KeyValue[K, V]
typestate KeyValue[K, V]:
states EmptyKV[K, V], HasKey[K, V], HasBoth[K, V]
transitions:
EmptyKV[K, V] -> HasKey[K, V]
HasKey[K, V] -> HasBoth[K, V]
HasBoth[K, V] -> EmptyKV[K, V]
proc setKey[K, V](kv: EmptyKV[K, V], key: K): HasKey[K, V] {.transition.} =
var obj = KeyValue[K, V](kv)
obj.key = key
result = HasKey[K, V](obj)
proc setValue[K, V](kv: HasKey[K, V], value: V): HasBoth[K, V] {.transition.} =
var obj = KeyValue[K, V](kv)
obj.value = value
result = HasBoth[K, V](obj)
Non-Transitions with Generics¶
Use {.notATransition.} for operations that don't change state:
proc peek[T](c: Full[T]): T {.notATransition.} =
Container[T](c).value
proc size[K, V](kv: HasBoth[K, V]): int {.notATransition.} =
1 # Always contains one key-value pair
Type Conversion Syntax¶
When converting between distinct generic types, use Type[params](value) syntax:
# Correct - explicit generic parameters
var cont = Container[T](c) # From distinct to base
result = Full[T](cont) # From base to distinct
# Wrong - method call syntax doesn't work with generics
# var cont = c.Container[T] # Compile error
Generated Helpers¶
Generic typestates get fully parameterized helper types:
| Helper | Non-Generic Example | Generic Example |
|---|---|---|
| State enum | FileState = enum fsClosed, fsOpen |
ContainerState = enum fsEmpty, fsFull |
| Union type | FileStates = Closed \| Open |
ContainerStates[T] = Empty[T] \| Full[T] |
| State procs | proc state(f: Closed): FileState |
proc state[T](c: Empty[T]): ContainerState |
Usage example:
# State enum works the same
check fsEmpty is ContainerState
check fsFull is ContainerState
# Union type is parameterized
proc acceptAny[T](c: ContainerStates[T]): ContainerState =
c.state
let e = Empty[int](Container[int](value: 0))
check acceptAny(e) == fsEmpty
# State procs are generic
let f = Full[string](Container[string](value: "hello"))
check f.state == fsFull
Branching Transitions with Generics¶
Generic typestates fully support branching transitions with parameterized branch types:
type
Container[T] = object
value: T
Empty[T] = distinct Container[T]
Full[T] = distinct Container[T]
Error[T] = distinct Container[T]
typestate Container[T]:
states Empty[T], Full[T], Error[T]
transitions:
Empty[T] -> (Full[T] | Error[T]) as FillResult[T]
Full[T] -> Empty[T]
This generates:
# Branch type enum (not parameterized)
type FillResultKind* = enum fFull, fError
# Branch type (parameterized)
type FillResult*[T] = object
case kind*: FillResultKind
of fFull: full*: Full[T]
of fError: error*: Error[T]
# Constructors (generic)
proc toFillResult*[T](s: Full[T]): FillResult[T]
proc toFillResult*[T](s: Error[T]): FillResult[T]
# Operator (generic)
template `->`*[T](_: typedesc[FillResult[T]], s: Full[T]): FillResult[T]
template `->`*[T](_: typedesc[FillResult[T]], s: Error[T]): FillResult[T]
Usage:
proc fill[T](e: Empty[T], val: T): FillResult[T] =
if val == default(T):
FillResult[T] -> Error[T](Container[T](e))
else:
var c = Container[T](e)
c.value = val
FillResult[T] -> Full[T](c)
let empty = Empty[int](Container[int](value: 0))
let result = fill(empty, 42)
case result.kind
of fFull: echo "Got value: ", Container[int](result.full).value
of fError: echo "Failed"
Supported Type Expressions¶
Generic typestates support various type expressions:
| Type Expression | Example | Notes |
|---|---|---|
| Simple generics | Container[T] |
Single type parameter |
| Multi-param generics | Map[K, V] |
Multiple type parameters |
| Nested generics | Container[seq[T]] |
Generic of generic |
| Constrained generics | Container[T: SomeInteger] |
With type bounds (see below) |
| Static parameters | Buffer[N: static int] |
Compile-time values |
Constrained Generic Parameters¶
When using constrained generic parameters, you must repeat the constraint in the typestate declaration. This applies to all constraints including type bounds (SomeInteger, SomeNumber) and static parameters (static int).
type
# Type definitions with constraints
Buffer[N: static int] = object
data: array[N, byte]
Empty[N: static int] = distinct Buffer[N]
Full[N: static int] = distinct Buffer[N]
# Constraint MUST be repeated in typestate header
typestate Buffer[N: static int]:
states Empty[N], Full[N]
transitions:
Empty[N] -> Full[N]
Full[N] -> Empty[N]
The same applies to type constraints:
type
NumericContainer[T: SomeNumber] = object
value: T
Unset[T: SomeNumber] = distinct NumericContainer[T]
Set[T: SomeNumber] = distinct NumericContainer[T]
# Constraint MUST be repeated
typestate NumericContainer[T: SomeNumber]:
states Unset[T], Set[T]
transitions:
Unset[T] -> Set[T]
Pattern: Builder with Required Fields¶
Use generics to track which fields have been set:
type
UserBuilder = object
name: string
email: string
NeedsBoth = distinct UserBuilder
NeedsEmail = distinct UserBuilder
Complete = distinct UserBuilder
typestate UserBuilder:
states NeedsBoth, NeedsEmail, Complete
transitions:
NeedsBoth -> NeedsEmail
NeedsEmail -> Complete
proc withName(b: NeedsBoth, name: string): NeedsEmail {.transition.} =
var builder = UserBuilder(b)
builder.name = name
result = NeedsEmail(builder)
proc withEmail(b: NeedsEmail, email: string): Complete {.transition.} =
var builder = UserBuilder(b)
builder.email = email
result = Complete(builder)
proc build(b: Complete): User {.notATransition.} =
let builder = UserBuilder(b)
User(name: builder.name, email: builder.email)
Pattern: Resource Wrapper¶
Wrap any resource type with acquire/release protocol:
type
Resource[T] = object
handle: T
Released[T] = distinct Resource[T]
Acquired[T] = distinct Resource[T]
typestate Resource[T]:
states Released[T], Acquired[T]
transitions:
Released[T] -> Acquired[T]
Acquired[T] -> Released[T]
proc acquire[T](r: Released[T], handle: T): Acquired[T] {.transition.} =
var res = Resource[T](r)
res.handle = handle
result = Acquired[T](res)
proc release[T](r: Acquired[T]): Released[T] {.transition.} =
result = Released[T](Resource[T](r))
proc use[T](r: Acquired[T]): T {.notATransition.} =
Resource[T](r).handle
Static Parameters as State Differentiation¶
A common pattern in embedded programming (see Nim forum discussion) uses static parameters to differentiate states at compile time:
# What you might want to write (DOES NOT WORK):
type
GPIO[Enabled: static bool, Dir: static Option[Direction]] = object
pin: int
typestate GPIO[Enabled: static bool, Dir: static Option[Direction]]:
states GPIO[false, none(Direction)], GPIO[true, none(Direction)]
transitions:
GPIO[false, none(Direction)] -> GPIO[true, none(Direction)]
This fails because all states share the same base name GPIO. The library cannot generate unique enum values, union types, or state procs when base names collide.
Use distinct wrapper types instead:
type
GPIO[Enabled: static bool, Dir: static Option[Direction]] = object
pin: int
# Wrapper types with unique base names
Disabled = distinct GPIO[false, none(Direction)]
EnabledNoDir = distinct GPIO[true, none(Direction)]
InputMode = distinct GPIO[true, some(Input)]
OutputMode = distinct GPIO[true, some(Output)]
typestate GPIO[Enabled: static bool, Dir: static Option[Direction]]:
states Disabled, EnabledNoDir, InputMode, OutputMode
transitions:
Disabled -> EnabledNoDir
EnabledNoDir -> InputMode
EnabledNoDir -> OutputMode
proc enable(g: Disabled): EnabledNoDir {.transition.} =
EnabledNoDir(GPIO[true, none(Direction)](pin: GPIO[false, none(Direction)](g).pin))
proc setInput(g: EnabledNoDir): InputMode {.transition.} =
InputMode(GPIO[true, some(Input)](pin: GPIO[true, none(Direction)](g).pin))
This approach:
- Preserves the zero-cost abstraction (distinct types compile away)
- Gives states meaningful names in error messages
- Works with all library features (enums, unions, branching)
Limitations¶
-
Constraints must be repeated: Generic constraints (
static int,SomeInteger, etc.) must be explicitly stated in the typestate header - they cannot be inferred from type definitions. See Constrained Generic Parameters. -
Unique base names required: All states must have distinct base type names. Using the same type with different parameters (like
GPIO[false]vsGPIO[true]) is not supported. See Static Parameters as State Differentiation for the workaround. -
Branch type params must match: Branch type parameters must use the same type variables as the typestate (e.g.,
FillResult[K]when typestate usesTwill fail). -
Distinct types with multiple params: Due to a Nim compiler limitation, using
distinctwith multiple generic params may cause C compilation errors. Use wrapper objects instead:
# May cause issues with distinct
type EmptyMap[K, V] = distinct Map[K, V]
# Works reliably
type EmptyMap[K, V] = object
inner: Map[K, V]
Next Steps¶
- DSL Reference - Complete syntax reference
- Examples - More usage patterns