cswork / libraryOpen practice workspace

Articles

F# Interview Questions: Discriminated Unions, Immutability, and Async Workflows

Explain F# discriminated unions, immutable values, async workflows, task interop, and cancellation through a concrete checkout scenario and failure tests.

An F# interview question can combine three deceptively simple requests: model a payment result, update an order without mutating the original, and call a .NET API asynchronously. A complete answer keeps those concerns separate. A discriminated union says which states are valid; immutable values make the before-and-after state explicit; an async workflow says when work is scheduled, awaited, and canceled. None of those features makes an external payment exactly once by itself.

Imagine a checkout service that obtains a quote, attempts a charge, and stores a receipt. The interviewer asks you to handle a declined card, a provider timeout, and user cancellation. A single nullable receiptId cannot tell these cases apart. A union can, and an immutable order update can preserve the previous version for comparison. Then the async boundary must specify what cancellation means if the provider has already accepted the charge. Practice the state-design part with Evaluate a Boolean Formula AST.

Evidence boundary: Official facts about F# unions, immutable values, async expressions, and task expressions come from Microsoft documentation checked September 2026. Candidate reports do not predict any particular employer's F# prompt. Inference includes the checkout scenario, proposed types, and tests; actual payment behavior depends on provider contracts.

How do discriminated unions improve a result type?

Microsoft's F# reference describes a discriminated union as one of several named cases, each optionally carrying its own data. For checkout, the states can make an important distinction:

type ChargeResult =
    | Approved of receiptId: string
    | Declined of reason: string
    | Unknown of operationId: string

Unknown is not a softer word for failure. It means the caller did not obtain a conclusive provider result, perhaps because the response timed out. Treating it as Declined would encourage an unsafe second charge; treating it as Approved would invent a receipt. A union makes that uncertainty explicit in the type, so every consumer has to choose a branch or deliberately include a wildcard. The type itself cannot verify provider truth, but it prevents callers from collapsing incompatible meanings into one nullable string.

CaseKnown factNext application action
Approved receiptIdProvider returned a confirmed receiptPersist and expose confirmed result
Declined reasonProvider gave a definitive rejectionTell the user and avoid automatic retry of the same invalid input
Unknown operationIdOutcome could not be confirmedReconcile by stable operation ID before retry

This model is an interview example, not an official F# payment API. If partial captures and refunds exist, add types or records that represent those distinctions. The value of the union is that names and payloads constrain what code can say. It can replace a small object hierarchy or scattered status strings when cases are known and meaningful.

What happens when a new case is added?

Suppose the provider adds RequiresAction of redirectUrl. A match that enumerates Approved, Declined, and Unknown needs a new decision. A catch-all branch might keep compilation quiet but hide the missing user-flow policy. In an interview, explain how you would review each consumer: UI copy, persistence, logging, retries, and cancellation. Compiler diagnostics help find incomplete matches; tests establish whether the chosen behavior is right.

F# pattern matching can destructure the payload:

let message result =
    match result with
    | Approved receiptId -> $"Receipt {receiptId}"
    | Declined reason -> $"Declined: {reason}"
    | Unknown operationId -> $"Checking {operationId}"

Do not claim that a type makes all invalid business states impossible. An Approved "" value is still constructible unless you validate the receipt ID or wrap it in a stronger type. And a charge may be approved in one system while the local database is stale. Use types for in-process invariants and durable reconciliation for cross-system truth.

What does immutable by default mean in F#?

F# let bindings are immutable unless explicitly declared mutable. Microsoft's values documentation says F# supports mutation but recommends limiting mutable variables to a small, controlled scope. An immutable record update creates a new record value rather than changing the old one:

type Order = { Id: string; Status: string; Version: int }

let before = { Id = "order-42"; Status = "pending"; Version = 1 }
let after = { before with Status = "paid"; Version = before.Version + 1 }

before still describes the previous state. This makes pure decision functions and tests easier to reason about. But the example's Status: string is intentionally weaker than the union model above; in a fuller design, status would be a DU. The interviewer may ask you to improve it. A new record value does not atomically update a database row. Concurrent writers still need a version check, transaction, or conflict resolution policy when persisting after.

Immutability is a default, not an absolute ban on mutation. A local mutable buffer inside a parser or a carefully owned cache can be reasonable. The key is to keep the mutation boundary explicit and avoid shared state that unrelated async work can race over. If you use a mutable flag to indicate cancellation while a provider call runs, ask whether that flag is visible across threads and whether it actually cancels the provider operation. A cancellation token or structured workflow is usually clearer.

When does an async { } workflow execute?

An F# async { ... } expression describes a computation of type Async<'T>. Creating the value does not, by itself, mean that the network request has run to completion. Microsoft describes explicit ways to start or run the computation, and let! binds the result of an asynchronous subcomputation within a workflow. This distinction matters in interviews because a candidate may construct three Async values and assume three requests are already in flight.

let getQuote orderId = async {
    let! quote = fetchQuoteAsync orderId
    return quote
}

Here fetchQuoteAsync stands for an F# async-returning function in the hypothetical application. To run it, the caller must start or compose getQuote orderId appropriately. A blocking Async.RunSynchronously can be acceptable at a command-line entry point, but Microsoft warns that it blocks the calling thread. In a server request path, blocking many threads while awaiting network I/O defeats the purpose of asynchronous work.

Asynchrony is not the same as CPU parallelism. The Microsoft tutorial explicitly separates asynchronous scheduling from multithreading. If a workflow spends most of its time awaiting a provider, async can free the caller to do other work. If it performs a long synchronous CPU computation, wrapping it in async {} does not magically distribute that computation across cores. Measure the actual bottleneck.

How do Async<'T> and .NET tasks differ?

F# can use its own async workflows and .NET Task values. Microsoft's async-expression guidance says task { } expressions are often preferable when extensive interoperability with APIs that produce or consume .NET tasks is required. F# async { } offers a succinct, compositional model for many F# workflows. Choose based on the surrounding API and cancellation needs rather than insisting that one is universally better.

The distinction includes cancellation behavior. Microsoft's task-expression reference notes that task expressions do not implicitly pass a cancellation token or perform implicit cancellation checks the way F# async expressions do. That does not mean every F# async operation automatically stops an external API call. Cancellation is cooperative and must be propagated to the actual operation where supported. If a provider has already committed a charge, canceling the local wait cannot undo it.

QuestionAsync<'T> workflowTask<'T>-based path
Typical fitF# composition and controlled executionHeavy .NET Task interoperability
Starting workExplicit start/run/compositionTask expression produces .NET task behavior
CancellationF# async model supports implicit token flow/checks in documented casesPass and check tokens explicitly as needed
External side effectStill needs reconciliationStill needs reconciliation

The table summarizes the design conversation; consult the current API documentation for exact behavior of the specific function you call. A library may expose Task even if your application prefers Async, and F# provides interop tools. Avoid adding conversions at every call site without a reason; put them at a boundary and test exception and cancellation behavior there.

What happens if a user cancels checkout mid-charge?

Start with the business timeline. At time one, the service creates a stable charge operation ID. At time two, it calls the provider. At time three, the user closes the page or a cancellation token is signaled. At time four, the provider may accept the charge anyway. The local async workflow might throw a cancellation exception or stop waiting, but the charge's external status is uncertain until checked. Return or persist Unknown operationId rather than falsely reporting Declined.

A background reconciliation flow can query the provider by operation ID and move the durable order to Approved or a confirmed failure state. If a retry is needed, reuse the provider's idempotency key where supported. Maintain an immutable value for the in-process result, but update durable state with concurrency control. The DU, record update, and async workflow each contribute to clarity; the operation ID and persistence enforce the cross-system invariant.

Test cancellation at several points: before the provider call; while it is pending; after it accepts but before local commit; and after commit but before the caller sees the response. Assert the final durable order state and provider call count, not just that a cancellation exception was thrown. A test with a fake provider that can pause at each boundary gives much stronger evidence than a happy-path async unit test.

What mistakes should an interviewer hear you correct?

“async {} means code is already running” confuses a described workflow with its execution. “Async means parallel” confuses I/O scheduling with CPU work. “Immutable means no race conditions anywhere” ignores databases, external providers, and any mutable values you explicitly introduce. “A wildcard handles all future union cases” hides new business requirements. “Cancellation reverses a payment” mistakes local control flow for provider state.

The better answer states each layer's contract: the DU records the outcome category, immutable records preserve prior in-process values, the async or task path controls work and cancellation, and a stable durable operation ID makes uncertain external outcomes recoverable. That layered answer is more valuable than syntax trivia.

Sources and Further Reading