Skip to content
learn

3. Tuples, records, unions and options

Code: the scripts examples/l03_*.fsx and the rejected snippets compile_fail/l03_*.fsx.

In C# and Java, data usually lives in classes: fields, a constructor, Equals and GetHashCode if you remember, and a class hierarchy when a value can take several shapes. Records (C# 9, Java 16) and sealed hierarchies (C# with abstract base classes, Java 17 with sealed interface) have made this shorter. F# has had short forms for all of it from the beginning, and they are the default way to model data:

You want C# Java F#
a few values together, no name (string, int) a record or Map.Entry tuple string * int
named fields, immutable, compared by value record record record
one of several shapes, each with its own data abstract base class and subclasses sealed interface and records discriminated union
a value that may be missing null, Nullable<T> null, Optional<T> Option<'T>
// A tuple groups values without declaring a type: here string * int
let lowString = ("E", 2)
printfn "%A" lowString
printfn "%s" (fst lowString)
// Deconstruction, like var (note, octave) = ... in C#
let (note, octave) = lowString
printfn "%s%d" note octave
// A function returns several values in a tuple
let octavesAndSemitones interval = (interval / 12, interval % 12)
let octaves, semitones = octavesAndSemitones 29 // the parentheses are optional
printfn "29 semitones = %d octaves and %d semitones" octaves semitones
// F# tuples are System.Tuple objects; struct tuples are System.ValueTuple, the tuples of C#
let structTuple = struct ("A", 4)
printfn "%s and %s" (lowString.GetType().Name) (structTuple.GetType().Name)
("E", 2)
E
E2
29 semitones = 2 octaves and 5 semitones
Tuple`2 and ValueTuple`2
  • The type of ("E", 2) is written string * int: the * reads “and”, as in a product of types.
  • fst and snd return the first and second element of a pair.
  • The last line shows a difference with C#: an F# tuple is a System.Tuple, an object on the heap, while C#’s (string, int) is a System.ValueTuple, a struct. struct ("A", 4) makes a value tuple, the one to use when calling C# code that expects one. The tuples page has the details.

A tuple is fine for two or three values that travel together for a moment. As soon as the values have names in your head, give them names in the code: a record.

type GuitarString = { Note: string; Octave: int; Gauge: float }
let low = { Note = "E"; Octave = 2; Gauge = 0.046 } // the labels tell F# the type
let high = { low with Octave = 4; Gauge = 0.010 } // copy and update, like with in C#
printfn "%s%d, gauge %.3f" low.Note low.Octave low.Gauge
printfn "%A" high
// Structural equality and comparison, generated by the compiler
printfn "%b" (high = { Note = "E"; Octave = 4; Gauge = 0.010 })
printfn "%b" (low = high)
printfn "%b" (low < high) // fields compared in order: Note, then Octave
E2, gauge 0.046
{ Note = "E"
Octave = 4
Gauge = 0.01 }
true
false
true
  • type GuitarString = { … } declares a record. The C# equivalent is record GuitarString(string Note, int Octave, double Gauge);.
  • The value { Note = "E"; Octave = 2; Gauge = 0.046 } has no type name in it: F# finds the record type from its labels.
  • { low with Octave = 4; Gauge = 0.010 } is a copy with two fields changed, C#’s low with { Octave = 4, Gauge = 0.010 }.
  • = compares the fields, and < compares them in order, Note first. C# records have value equality but no ordering; F# generates both, plus a hash code.

Every field must be given, and no field can be changed:

type GuitarString = { Note: string; Octave: int; Gauge: float }
let low = { Note = "E"; Octave = 2 }
l03_record_missing_field.fsx(3,11): error FS0764: No assignment given for field 'Gauge' of type 'FSI_0001.GuitarString'
let low = { Note = "E"; Octave = 2; Gauge = 0.046 }
low.Note <- "D"
l03_record_immutable.fsx(4,1): error FS0005: This field is not mutable

FSI_0001 is the module F# Interactive wraps the script in. A C# record with positional parameters behaves the same way, but a C# record can also have set properties, and a C# new can leave a property at its default. An F# record always has all its values, from the start.

A C major chord on a guitar is played x32010: the low E string is muted, then fret 3, fret 2, an open string, fret 1, an open string. Each string is in one of several situations, and each situation has different data. In C#, you would write a small class hierarchy:

abstract record Fingering;
sealed record Open : Fingering;
sealed record Fretted(int Fret) : Fingering;
sealed record Barre(int Fret, int Strings) : Fingering;
sealed record Muted : Fingering;

In F#, it’s one type, a discriminated union:

// A discriminated union: a value is exactly one of the cases
type Accidental =
| Natural
| Sharp
| Flat
// Each case can carry its own data
type Fingering =
| Open
| Fretted of fret: int
| Barre of fret: int * strings: int
| Muted
// C major, from the low E string to the high E string: x32010
let cMajor = [ Muted; Fretted 3; Fretted 2; Open; Fretted 1; Open ]
printfn "%A" cMajor
let fMajor = Barre(fret = 1, strings = 6) // named fields
printfn "%A" fMajor
printfn "%b %b" (Fretted 3 = Fretted 3) (Sharp = Flat)
[Muted; Fretted 3; Fretted 2; Open; Fretted 1; Open]
Barre (1, 6)
true false
  • Accidental has cases without data: it’s an enum, but a closed one. A C# enum accepts (Accidental)42; a union value can only be one of its cases.
  • Fretted of fret: int carries an int, named fret. Barre of fret: int * strings: int carries two values. The names are optional, and useful: they show up in tooltips and allow Barre(fret = 1, strings = 6).
  • Each case is also a constructor function: Fretted 3 builds a value of type Fingering, and Fretted alone is a function int -> Fingering.
  • Equality and comparison are generated, as for records.

The real advantage appears with pattern matching, in lesson 4: the compiler knows the complete list of cases and warns when a match forgets one. A C# switch over the record hierarchy can’t know that nobody added a fifth subclass in another file.

Guitar Alchemist parses chord symbols such as F#m7b5/C into a syntax tree. The types of that tree are in ChordAst.fs, and they are records and unions:

namespace GA.Business.DSL.Types
type QualityType =
| Major
| Minor
| Diminished
| Augmented
| Suspended
| Dominant // Derived during semantic analysis usually
type AccidentalType =
| Natural
| Sharp
| Flat
| DoubleSharp
| DoubleFlat
type ChordComponent =
| Extension of string
| Alteration of AccidentalType * string
| Omission of string
| Alt
type ChordAst =
{ Root: string
RootAccidental: AccidentalType
Quality: QualityType option
Components: ChordComponent list
Bass: (string * AccidentalType) option }

Twenty-seven lines describe every chord symbol GA understands. The types nest: a ChordAst record holds a list of ChordComponent unions, one of whose cases holds an AccidentalType and a string. Quality: QualityType option says that a chord may have no written quality (C7), and Bass: (string * AccidentalType) option that it may have no bass note, and that a bass note is a tuple of a letter and an accidental. The C# version would be a dozen files.

The course loads this file and GA’s renderer, and builds F#m7b5/C by hand (examples/l03_ga_chords.fsx):

// Guitar Alchemist's chord AST and renderer, loaded from the copies in external/ga
#load "../external/ga/ChordAst.fs" "../external/ga/ChordRenderer.fs"
open GA.Business.DSL.Types
open GA.Business.DSL.Generators
// F#m7b5/C as a value of GA's ChordAst record
let halfDiminished =
{ Root = "F"
RootAccidental = Sharp
Quality = Some Minor
Components = [ Extension "7"; Alteration(Flat, "5") ]
Bass = Some("C", Natural) }
printfn "%s" (ChordRenderer.render halfDiminished)
printfn "%A" halfDiminished
let c = { Root = "C"; RootAccidental = Natural; Quality = None; Components = []; Bass = None }
printfn "%s" (ChordRenderer.render c)
printfn "%s" (ChordRenderer.render { c with Quality = Some Major; Components = [ Extension "9" ] })
F#m7b5/C
{ Root = "F"
RootAccidental = Sharp
Quality = Some Minor
Components = [Extension "7"; Alteration (Flat, "5")]
Bass = Some ("C", Natural) }
C
Cmaj9

%A prints the value in F# syntax, which you can paste back into a script. ChordRenderer.render turns the tree back into text; lesson 4 reads its code.

Some Minor and None above are the two cases of Option, a union defined in FSharp.Core:

type Option<'T> =
| None
| Some of 'T

int option is another way to write Option<int>. An int option is either Some 2 or None, and the type says so: code that receives it must handle both. An F# string, on the other hand, is not supposed to be null.

// Option: Some value, or None, instead of null
let capo: int option = Some 2
let noCapo: int option = None
let describe capo =
capo
|> Option.map (fun fret -> $"capo on fret {fret}")
|> Option.defaultValue "no capo"
printfn "%s, %s" (describe capo) (describe noCapo)
printfn "%A %A" capo noCapo
// A .NET method may return null: Option.ofObj turns it into None
let variable = System.Environment.GetEnvironmentVariable "FSHARP_COURSE_VARIABLE_THAT_IS_NOT_SET"
printfn "%A" (Option.ofObj variable)
capo on fret 2, no capo
Some 2 None
None
  • Option.map applies a function to the value inside Some, and leaves None alone: C#’s capo?.ToString(), but for any function. Option.defaultValue is C#’s ??. GA’s renderer uses the same two functions for the quality of a chord: ast.Quality |> Option.map renderQuality |> Option.defaultValue "".
  • Option.ofObj is the bridge from .NET APIs, which return null for “nothing”.

None isn’t null in disguise that you could forget to check: the type int option isn’t int, so you can’t add capo + 1 without saying what happens when there is no capo. And null isn’t an option:

let capo: int option = null
l03_option_null.fsx(1,24): error FS0043: The type 'int option' does not have 'null' as a proper value

Compare with Java’s Optional<T>, a class whose variable can itself be null, and with C#’s nullable reference types, which are warnings added on top of types that still accept null. F# 9 added nullable reference types for interop with .NET APIs; lesson 9 covers them.

A union with one case wraps a value into a type of its own. TARS gives its identifiers distinct types this way, in Primitives.fs:

/// Unique identifier for an agent
type AgentId = AgentId of Guid
/// Unique identifier for a correlation/conversation
type CorrelationId = CorrelationId of Guid

Both hold a Guid, but they aren’t interchangeable:

open System
type AgentId = AgentId of Guid
type CorrelationId = CorrelationId of Guid
let describeAgent (AgentId id) = $"agent {id}"
let conversation = CorrelationId(Guid.NewGuid())
printfn "%s" (describeAgent conversation)
l03_single_case_mixup.fsx(9,29): error FS0001: This expression was expected to have type
'AgentId'
but here has type
'CorrelationId'

With plain Guid parameters, the compiler would have accepted the call. let describeAgent (AgentId id) = … unwraps the value in the parameter itself, a pattern that lesson 4 explains (examples/l03_single_case.fsx runs the correct call). In C#, the closest is readonly record struct AgentId(Guid Value): more ceremony, the same safety.

Union cases are names in scope, like functions. If two unions have a case with the same name, the one defined last wins. Ok and Error are the cases of FSharp.Core’s Result type (lesson 7), always in scope, so a union with an Error case of its own hides Result.Error from every line below it. Here is the situation reduced to a few lines:

// Reduced from TARS (v2/src/Tars.Core/Domain.fs): a union with a case named Error
type PartialFailure =
| Warning of message: string
| Error of message: string
let checkFret fret : Result<int, string> =
if fret >= 0 then Ok fret else Error "negative fret"
l03_case_shadowing.fsx(7,36): error FS0001: All branches of an 'if' expression must return values implicitly convertible to the type of the first branch, which here is 'Result<int,string>'. This branch returns a value of type 'PartialFailure'.

Error "negative fret" built a PartialFailure, not a Result. TARS has exactly this. Domain.fs, the eighth file of Tars.Core, declares:

/// Represents a non-fatal issue encountered during execution
type PartialFailure =
| Warning of message: string
| Error of message: string
| Degradation of feature: string * reason: string
| Timeout of operation: string * duration: TimeSpan
| SubAgentTimeout of agentId: AgentId * taskId: Guid
| ToolError of tool: string * error: string
| LowConfidence of score: float * details: string
| ProtocolViolation of message: string
| ConstraintViolation of violation: string
/// Represents the outcome of an agentic operation, supporting partial success
type ExecutionOutcome<'T> =
| Success of value: 'T
| PartialSuccess of value: 'T * warnings: PartialFailure list
| Failure of errors: PartialFailure list

The effect follows the file order of lesson 1. Budget.fs, compiled before Domain.fs, writes a plain Error e and gets Result.Error. Files compiled after it must qualify: ConstitutionLoader.fs writes Result.Error, and ArcTypes.fs goes as far as FSharp.Core.Result.Error. At this commit, v2/src contains 342 occurrences of Result.Error. Failure collides in the same way: the Performative union a few lines above has a Failure case too, and TARS’s AgentWorkflow.fs writes ExecutionOutcome.Success, ExecutionOutcome.PartialSuccess and ExecutionOutcome.Failure in full.

F#’s answer is the RequireQualifiedAccess attribute: the cases of the union must then be written with its name, so they hide nothing.

// RequireQualifiedAccess: the cases must be written PartialFailure.Warning and PartialFailure.Error
[<RequireQualifiedAccess>]
type PartialFailure =
| Warning of message: string
| Error of message: string
let checkFret fret : Result<int, string> =
if fret >= 0 then Ok fret else Error "negative fret" // Error is Result's case again
printfn "%A" (checkFret 3)
printfn "%A" (checkFret -1)
printfn "%A" (PartialFailure.Warning "low confidence")
Ok 3
Error "negative fret"
Warning "low confidence"

TARS uses it elsewhere: AgentDefinition.fs marks its AgentSkill union, whose cases Reasoning, Planning and Coding would otherwise collide with those of AgentDomain and CapabilityKind. Adding it to an existing union like PartialFailure is a breaking change for the code that uses it, which must then qualify every case: it’s a decision best made when the type is created.

  • A tuple (string * int) groups values without names; F# tuples are System.Tuple, and struct (…) makes C#’s value tuples.
  • A record has named, immutable fields, structural equality and comparison, and { r with … } to copy it with changes.
  • A discriminated union lists every shape a value can take, each case with its own data: it replaces enums and small class hierarchies.
  • Option (Some / None) replaces null; Option.map and Option.defaultValue work with it, Option.ofObj converts from .NET.
  • Single-case unions give primitives distinct types; [<RequireQualifiedAccess>] keeps union cases from hiding other names, such as Result.Error.
  1. Declare a record Tuning with a name, a list of notes (string list) and an optional capo fret. Create the standard tuning, then Drop D (the low string tuned down to D2) and the standard tuning with a capo on fret 2 by copying it. Print Drop D, and check with = that the capo version equals a second copy made the same way.
Solution

exercises/l03_ex_record.fsx:

type Tuning = { Name: string; Notes: string list; Capo: int option }
let standard = { Name = "Standard"; Notes = [ "E2"; "A2"; "D3"; "G3"; "B3"; "E4" ]; Capo = None }
let dropD = { standard with Name = "Drop D"; Notes = [ "D2"; "A2"; "D3"; "G3"; "B3"; "E4" ] }
let capoOn2 = { standard with Capo = Some 2 }
printfn "%A" dropD
printfn "%b" (capoOn2 = { standard with Capo = Some 2 })
printfn "%b" (standard.Notes = capoOn2.Notes)
{ Name = "Drop D"
Notes = ["D2"; "A2"; "D3"; "G3"; "B3"; "E4"]
Capo = None }
true
true

Equality goes all the way down: the lists are compared element by element, and Some 2 = Some 2. capoOn2 shares its Notes list with standard, which is safe because neither can change it.

  1. A guitar lick is a sequence of techniques: a picked note, a hammer-on from one fret to another, a slide from one fret to another, or a bend by a number of semitones (1.0 for a whole tone). Model it with a union, write a lick of four techniques, and print it. Is a slide from 7 to 9 equal to a slide from 9 to 7?
Solution

exercises/l03_ex_union.fsx:

type Technique =
| Picked
| HammerOn of fromFret: int * toFret: int
| Slide of fromFret: int * toFret: int
| Bend of semitones: float
let lick = [ Picked; HammerOn(5, 7); Slide(fromFret = 7, toFret = 9); Bend 1.0 ]
printfn "%A" lick
printfn "%b" (Slide(7, 9) = Slide(9, 7))
[Picked; HammerOn (5, 7); Slide (7, 9); Bend 1.0]
false

The values are compared in order, so the two slides differ, as they do on the guitar. HammerOn(5, 7) and Slide(5, 7) differ too, although they carry the same data: the case is part of the value.

  1. With GA’s ChordAst and ChordRenderer (load them as in Real code: GA’s chord AST), build the chords Bbmaj7/D and Eaug, and render them. Look at how GA writes Cmaj9 above: where does maj7 go?
Solution

exercises/l03_ex_ga_chord.fsx:

#load "../external/ga/ChordAst.fs" "../external/ga/ChordRenderer.fs"
open GA.Business.DSL.Types
open GA.Business.DSL.Generators
let bFlatMajor7OverD =
{ Root = "B"
RootAccidental = Flat
Quality = None
Components = [ Extension "maj7" ]
Bass = Some("D", Natural) }
let eAugmented = { bFlatMajor7OverD with Root = "E"; RootAccidental = Natural; Quality = Some Augmented; Components = []; Bass = None }
printfn "%s" (ChordRenderer.render bFlatMajor7OverD)
printfn "%s" (ChordRenderer.render eAugmented)
// The same chord written with a quality: same text, different tree
let withQuality = { bFlatMajor7OverD with Quality = Some Major; Components = [ Extension "7" ] }
printfn "%s %b" (ChordRenderer.render withQuality) (withQuality = bFlatMajor7OverD)
Bbmaj7/D
Eaug
Bbmaj7/D false

The flat is the accidental of the root, not part of the root’s letter. maj7 can be written two ways: no quality and Extension "maj7", or Quality = Some Major with Extension "7". Both render Bbmaj7/D, but the two trees aren’t equal. GA’s parser produces the first form: the comments of ChordParser.fs keep maj7 as one token. Two trees for one chord means that = can call the same chord different; lesson 12 is about designing types where that can’t happen.