Exploring F# Discriminated Unions for State Machine Logic
State machines appear wherever software moves through a defined sequence of conditions: a payment awaiting authorisation, a server being provisioned, a support ticket being escalated, or a device shifting between online and offline modes. The implementation often starts simply, then grows into a tangle of Boolean flags, nullable fields, and special-case checks. Learn more about ネットワークの回線契約前に自宅の光ファイバー接続口を確認するポイント.
F# offers a clearer model through discriminated unions. They allow each meaningful state to become a named case, while carrying only the data that belongs to that case. This makes invalid combinations harder to represent and gives the compiler a useful role in checking transitions.
For infrastructure and operations work, that clarity matters. A cloud migration workflow may need to handle queued jobs, temporary failures, retries, approvals, and completed resources. Representing those stages explicitly can make monitoring, testing, and incident diagnosis considerably less painful, whether the system runs in Sydney, Perth, or a small regional office relying on an NBN connection.
Turning Workflow States Into Types
A discriminated union can describe the complete set of states in a workflow:
type DeploymentState =
| Planned of application:string
| Building of buildId:string
| AwaitingApproval of environment:string
| Deploying of version:string
| Live of url:string
| Failed of reason:string
Each case tells us something useful. A Planned deployment has an application name, while a Live deployment has a URL. There is no need to attach every possible property to one large record and then wonder whether url is meaningful while the deployment is still building.
This approach is especially valuable when state transitions carry information forward. A build identifier can be required before deployment begins, and a failure reason can be preserved for logging or retry decisions. The type becomes a compact description of the domain rather than a loose collection of fields.
Pattern matching then makes the transition rules visible:
let advance state =
match state with
| Planned app -> Building (sprintf "%s-build-001" app)
| Building build -> AwaitingApproval "staging"
| AwaitingApproval env -> Deploying "2.4.0"
| Deploying version -> Live "https://service.example"
| Live _ -> state
| Failed _ -> state
The compiler can warn when a new case is added but not handled. That feedback is easy to appreciate in a production codebase where a newly introduced Paused or Cancelled state could otherwise pass silently through a default branch.
Replacing Boolean Flags With Explicit Meaning
A common alternative is a record such as isRunning, isApproved, hasFailed, and isComplete. These flags may look convenient, but they permit contradictory values. A job can accidentally be marked as running and complete, or failed and approved, unless every function remembers to enforce the rules.
A discriminated union prevents many of those combinations at the type level:
type JobState =
| Queued
| Running of startedAt:System.DateTime
| Succeeded of outputPath:string
| RetryableFailure of attempts:int * message:string
| PermanentFailure of message:string
The distinction between retryable and permanent failure is operationally useful. An automated worker can retry one case, alert an operator for another, and ignore a successful job. The code reads like a runbook, which helps when an Australian team is handing an incident from an early-morning shift in Brisbane to colleagues working later in Perth.
The same model can represent infrastructure resources. A virtual machine might be Requested, Provisioning, Ready, Draining, or Decommissioned. Each case can carry specific metadata without forcing unrelated states to share a common payload. That design also makes API responses and log events easier to interpret.
Making Transitions Safe and Testable
A robust state machine should define which transitions are legal. Returning an option or a result gives the caller a clear way to handle invalid requests:
type Command =
| Start
| Approve
| Release
| Retry
| Cancel
type TransitionError =
| InvalidCommand of state:DeploymentState * command:Command
let transition state command =
match state, command with
| Planned _, Start -> Ok (Building "build-001")
| AwaitingApproval _, Approve -> Ok (Deploying "2.4.0")
| Failed _, Retry -> Ok (Building "retry-build")
| Live _, Release -> Ok (Planned "service")
| _ -> Error (InvalidCommand (state, command))
The nested pattern match documents both the current state and the requested action. An invalid command does not need to throw an unexpected exception or mutate a half-valid object. It becomes an ordinary value that can be logged, measured, or shown in an operations dashboard.
This style is a good fit for property-based testing and table-driven tests. Tests can assert that a successful deployment never moves directly to Building, that cancellation is rejected after decommissioning, and that retries increase the attempt count. A state machine with a small number of cases can often be tested exhaustively.
External systems still need careful boundaries. If a transition triggers a cloud API call, database update, or queue message, keep the pure decision logic separate from the side effect. The pure function decides what should happen; an adapter performs it and reports success or failure back into the state machine.
Connecting State Logic To Real Operations
Stateful workflows are common in deployment pipelines. A PHP service may move through validation, image creation, rollout, health checking, and rollback. A practical Docker deployment guide provides useful context for the kind of container lifecycle that can sit behind these states, while F# can model the orchestration around it.
Monitoring systems benefit from the same distinction. Instead of emitting a generic “service problem” event, an agent can report ConnectionLost, HighLatency, Recovering, or Healthy. Alerting rules can then use the state and its payload to avoid repeated notifications during a known recovery cycle.
Australian infrastructure adds a few real-world considerations. A workflow may need a maintenance window aligned with AEST or AWST, and regional sites can have very different network reliability. Before automating a connection-dependent provisioning process, teams should review fibre connection checks rather than assuming a property has the expected termination point. For a home lab in regional New South Wales or a small office outside Adelaide, an explicit WaitingForConnectivity state may be more honest than treating every timeout as a generic failure.
Practical Design Checklist
When modelling a workflow, begin with domain language rather than implementation details. Ask which states an operator would recognise, what information is required in each state, and which actions are allowed. Keep the union focused: a deployment state machine should not also encode every detail of billing, user identity, and network topology.
Useful questions for the initial model include:
- Can two cases describe the same real-world condition?
- Does each case carry only data that is meaningful there?
- Which transitions are commands, and which are external events?
- What should happen when an API call times out?
- Which states require operator attention?
Once the cases are stable, consider how the state will be stored and transported. F# discriminated unions are excellent inside application code, but a database or message queue may need a serialisation format with an explicit case name and payload. Version those messages carefully when services are deployed independently.
A second review should focus on failure and recovery behaviour:
- Is a failed operation retryable, permanent, or unknown?
- Can a transition be safely repeated after a timeout?
- Is there an audit event for each important change?
- Does a new state produce a compiler warning in every relevant match?
- Can tests cover the legal transition graph?
Avoid using a wildcard pattern simply to silence warnings. A catch-all branch can hide a new case and turn a deliberate design change into an unnoticed production behaviour. If a fallback is genuinely appropriate, make it narrow and record enough context for diagnosis.
Extending The Pattern Without Losing Simplicity
As a state machine grows, separate the core lifecycle from supporting concerns. A deployment union can track release progress, while a separate type handles approval policy or health-check results. Combining every concern into one enormous union often makes transitions harder to understand.
Nested types can express richer rules without returning to Boolean flags:
type Health =
| Unknown
| Passing
| Failing of consecutiveChecks:int
type ServiceState =
| Starting
| Running of Health
| Stopping
| Stopped
This model distinguishes “running but unhealthy” from “not running”, which is important for alerting and automated remediation. It also leaves room for a health policy to decide whether a service should be restarted, drained from a load balancer, or left alone for an operator.
The best state machines remain boring to read. Each case should represent a real business or operational concept, transitions should be small and deterministic, and side effects should sit at the edges. With those boundaries in place, F# discriminated unions provide a dependable foundation for cloud orchestration, monitoring agents, deployment tools, and long-lived infrastructure services.
Apply the pattern to one contained workflow first, such as a deployment job or backup process. Define its states, write the transition function, add tests for valid and invalid commands, and inspect the resulting logs. That small exercise can expose hidden assumptions before they become an incident, while giving the wider codebase a safer vocabulary for change.
Karl Katzke