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.

Experience

Information Technology Consulting

Independent Practice

Provides IT consulting services focused on infrastructure planning, cloud migration strategy, and systems architecture. Engagements draw on years of hands-on sysadmin and development experience across Linux, Windows, and hybrid environments.

K9 Search & Rescue Volunteer

Ongoing

Active participant in K9 Search & Rescue operations, combining technical logistics skills with field support for canine search teams.

Karl Katzke's Blog

October 2006 – May 2014

Published a long-running personal technology blog covering cloud vs. in-house infrastructure, F# and Mono on OSX, hardware vendor critiques, RAID card performance analysis, and sysadmin storytelling. Notable posts include "When Sysadmins Ruled the Earth" (May 15, 2014) and "Getting Started with F# and Mono on OSX" (December 22, 2012).

Credentials

A small badge icon with a shield shape in muted blue tones on a light background

Systems Administration

Deep experience with Linux (RHEL, SLES, CentOS), high-availability clusters, and STONITH configurations.

A small badge icon with a gear shape in muted blue tones on a light background

Cloud Infrastructure

Practical knowledge of AWS EC2, reserved instances, and cost analysis for cloud vs. on-premises deployments.

A small badge icon with a code symbol in muted blue tones on a light background

Development

Proficient in F#, PHP (Symfony), and cross-platform tooling including Mono and MonoDevelop on OSX.

Studies

F# & Functional Programming

Self-directed, 2012

Explored strongly typed functional programming with F# on OSX using the Mono runtime. Published a detailed getting-started guide covering toolchain setup and cross-platform game development research.

High-Availability & Cluster Management

Professional Development, 2009

Configured and documented crm_mon email alerting for STONITH events on SLES11-HAE clusters, integrating with Nagios monitoring for production environments.

Hardware & Storage Performance

Ongoing

Conducted hands-on benchmarking of SATA/SAS RAID controllers including HighPoint RocketRaid 2740 and LSI/SuperMicro AOC-USASLP2-H8iR, comparing against software RAID configurations.

Skills

A small icon representing a server with clean geometric lines in slate blue

Linux Administration

RHEL, SLES, CentOS — package management, kernel tuning, HA clustering, and monitoring integration.

A small icon representing a cloud shape with clean geometric lines in slate blue

Cloud Architecture

AWS EC2, reserved-instance planning, cost modeling, and hybrid infrastructure strategy.

A small icon representing code brackets with clean geometric lines in slate blue

F# & .NET/Mono

Functional programming on OSX, MonoDevelop toolchain, and cross-platform game-dev exploration.

A small icon representing a database cylinder with clean geometric lines in slate blue

PHP & Symfony

Web application development with the Symfony framework and the broader PHP ecosystem.

A small icon representing a storage drive with clean geometric lines in slate blue

Storage & RAID

SATA/SAS controller evaluation, md RAID configuration, and performance benchmarking.

A small icon representing a shield with clean geometric lines in slate blue

High Availability

Pacemaker, STONITH, crm_mon alerting, and Nagios integration for production cluster monitoring.