When to Use a Discriminated Union vs a Record Type in F#

·12 min read·James Radley

One of the most common questions I get from developers moving into F# is also one of the most fundamental: when do I use a discriminated union, and when do I use a record type?

Both constructs are central to idiomatic F# code. Both help you model your domain precisely. And both will feel unfamiliar if you're coming from an OOP background where classes handle everything. Here is how I think about the distinction.

The Core Difference

A record type models a thing that is several things simultaneously. A customer has a name, an email address, and a date of birth — all at once, all the time.

A discriminated union (DU) models a thing that is one of several possible things. A payment method is either a credit card or a bank transfer or cash — never more than one at a time.

In type theory, records are product types (the values are the product of all their fields). Discriminated unions are sum types (the values are the sum of all their cases, meaning any one of them).

This distinction maps cleanly onto how you model real domains:

  • Record: use when the entity has multiple properties that coexist
  • Discriminated union: use when the entity can be in one of several mutually exclusive states

Records in Practice

type Customer = {
    Name: string
    Email: string
    DateOfBirth: System.DateTime
}

A Customer always has all three fields. You can pattern match on records, use with expressions to create modified copies, and compare them structurally by default.

// Creating a record
let customer = { Name = "Alice"; Email = "[email protected]"; DateOfBirth = DateTime(1990, 3, 15) }

// Non-destructive update — creates a new record with Email changed
let updated = { customer with Email = "[email protected]" }

// Structural equality by default
let isSame = customer = updated  // false

Record types shine when you are modelling data that flows through transformations — the core use case of functional programming. A function takes a Customer and returns a modified Customer. The type makes the transformation explicit and checkable.

Discriminated Unions in Practice

type PaymentMethod =
    | CreditCard of cardNumber: string * expiryDate: string
    | BankTransfer of accountNumber: string * sortCode: string
    | Cash

type OrderStatus =
    | Pending
    | Processing of startedAt: System.DateTime
    | Completed of completedAt: System.DateTime * reference: string
    | Cancelled of reason: string

A PaymentMethod is always exactly one of those three cases. An OrderStatus is always exactly one of those four states.

The power of discriminated unions is in exhaustive pattern matching. When you write a match expression over a DU, the compiler forces you to handle every case:

let describeStatus status =
    match status with
    | Pending -> "Awaiting processing"
    | Processing started -> sprintf "Processing since %A" started
    | Completed (at, ref) -> sprintf "Completed %A — ref %s" at ref
    | Cancelled reason -> sprintf "Cancelled: %s" reason

Add a new case to OrderStatus and every pattern match in the codebase becomes a compiler error until you handle it. This is the type system preventing entire categories of bugs at compile time, before the code ever runs.

Making Illegal States Unrepresentable

Here is a useful heuristic. Ask yourself: could this thing be in an invalid state?

If the answer is yes — if it might be partially constructed, or in an indeterminate condition — that is often a signal you want a discriminated union with an explicit case for that state.

// Fragile: both options could be set simultaneously — nothing prevents it
type Order = {
    Items: Item list
    CompletedAt: System.DateTime option
    CancelledAt: System.DateTime option
}

// Correct: exactly one state is possible at any time
type OrderState =
    | Draft of items: Item list
    | Completed of items: Item list * completedAt: System.DateTime
    | Cancelled of items: Item list * cancelledAt: System.DateTime * reason: string

The second version makes illegal states unrepresentable. You cannot have both completedAt and cancelledAt set. The type enforces the business rule without a runtime check.

Single-Case Discriminated Unions for Type Safety

One of the most underused patterns in F# is the single-case DU — a DU with exactly one case, used purely to give a primitive a distinct type:

type CustomerId = CustomerId of int
type OrderId = OrderId of int
type ProductId = ProductId of int

let getOrder (orderId: OrderId) (customerId: CustomerId) = ...

Without this pattern, getOrder takes two int parameters, and nothing stops a caller from accidentally passing them in the wrong order. With single-case DUs, the compiler makes that mistake impossible. The cost is near-zero. The safety benefit is real.

Unwrapping is clean:

let (CustomerId id) = customerId   // pattern match in binding
// or
let id = match customerId with CustomerId i -> i

Use single-case DUs whenever a raw primitive represents a domain concept with identity. Customer IDs, order references, email addresses, currency amounts — all good candidates.

Type Aliases: When Neither Fits

Sometimes you just want a name for a type, without the overhead of wrapping and unwrapping:

type Name = string
type Quantity = int

Type aliases are transparent — the compiler treats Name and string as identical. This means they carry no type safety. They are documentation, not enforcement.

Use type aliases for clarity when you do not need the compiler to distinguish between two uses of the same primitive. Use single-case DUs when you do.

Combining Them

In practice, you use both. Records model the data within a state; discriminated unions model which state you are in.

type Address = {
    Street: string
    City: string
    PostCode: string
}

type DeliveryOption =
    | HomeDelivery of address: Address
    | ClickAndCollect of storeId: string
    | DigitalDelivery of email: string

type CheckoutResult =
    | Success of orderId: OrderId * delivery: DeliveryOption
    | PaymentDeclined of reason: string
    | OutOfStock of productIds: ProductId list

Address is a record — it always has all three fields. DeliveryOption is a DU — it is always exactly one of three options. CheckoutResult is a DU whose cases carry records and other DUs. The types compose naturally.

A Decision Guide

When deciding which construct to reach for, work through these questions:

Can the entity be in mutually exclusive states? Yes → discriminated union. No → consider a record.

Does the entity always have all its properties at the same time? Yes → record. No → model each valid state explicitly with a DU.

Do you need to prevent mix-ups between two different uses of the same primitive? Yes → single-case DU. No → type alias if clarity helps, nothing if not.

Are you modelling transformation of data through a pipeline? Records work particularly well here — they are designed for non-destructive updates via with expressions.

Are you modelling a workflow with distinct stages? A DU for the stages, records for the data at each stage.

Common Mistakes

Over-using option fields on records: If a field is None in some states and Some in others, that is usually a sign your type should be a DU with explicit states rather than a record with optional fields.

Nesting DUs too deeply: A DU case that carries another DU that carries another DU becomes hard to pattern match and reason about. Flatten where possible, or extract named record types to give intermediate values meaning.

Forgetting exhaustive matching: The compiler warning about incomplete pattern matches is not noise — it is telling you that your logic has gaps. Treat it as an error.

Using classes when records would do: Coming from C# or Java, the instinct is to reach for a class. For data that flows through transformations without behaviour, a record is simpler, structurally comparable by default, and idiomatic F#.

A Note on the Mind-Body Connection

Learning to think in types is, in my experience, one of the more cognitively demanding shifts in software development — not because it is complicated, but because it requires sitting with ambiguity while a new mental model assembles itself. The discomfort is legitimate and temporary.

The reward is code that says what it means. When the type system carries your domain logic, the gap between the model in your head and the model in the code narrows substantially. That clarity has a quality of relief to it that I did not expect when I started learning F#.

The combination of records, DUs, and single-case DUs covers the vast majority of domain modelling needs in F#. Start with one small piece of your domain. Model it with these constructs. Watch what the compiler catches. The rest follows.

Related Research