jonnifyfsharp

Dive C2.03.1

From null to Option

There is no null to forget. Absence is a value the compiler makes you read.

In C#, a reference can be null, and the type does not warn you: a method returning string might hand back null, and the dereference that follows throws a NullReferenceException at runtime. F# removes null from its own types. A value that might be absent has type Option<'T> — a two-case union, Some v when present and None when not. To read it, you match, and because the match must be total, the compiler will not let you ignore the None case. The missing-value bug moves from a runtime crash to a thing that does not compile.

C2.03.1 · part 1

Some of 'T, or None

Option is a union with one type parameter and two cases. Its declaration in FSharp.Core is the two constructors below: Some wraps a present value; None carries nothing. A function whose answer might be absent returns one, and the signature says so — int -> string option reads as "give me an id, get back a name that might not be there."

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

A lookup over a map is the everyday case. Map.tryFind returns Some when the key is present and None when it is not — the name's try prefix is the F# convention for "returns an Option instead of throwing." The caller reads it with a match that names both outcomes.

let users = Map [ (1, "Ada"); (2, "Grace"); (3, "Edsger") ]
let findName id = Map.tryFind id users
// val findName : int -> string option

findName 2                        // val it : string option = Some "Grace"
findName 9                        // val it : string option = None
Option<'T>
a union: Some of 'T when a value is present, None when absent.
None
one value, no payload — the typed stand-in for "missing", with no way to dereference it by accident.
tryFind
the try prefix marks a function that returns an Option rather than throwing on a miss.
vs. null
null is any reference and shows in no type; None is a value of a named option type the caller must handle.
C2.03.1 · part 2

The C# the reader knows, beside the F#

In C#, the same lookup returns a nullable reference, string?. The ? is a hint to the analyzer, not a wall: the code still compiles if the caller forgets to check, and the ?. and ?? operators are a convention for guarding, not a requirement. The F# version returns an Option, and the match is the wall — leave out None and the compiler reports an incomplete match.

C# — a nullable reference and the guard dance

string? FindName(int id) =>
    _byId.TryGetValue(id, out var n) ? n : null;

// the caller must remember to guard — nothing forces it:
var shown = FindName(9)?.ToUpper() ?? "unknown";   // "unknown"
var oops  = FindName(9).ToUpper();                  // compiles, throws NullReferenceException

F# — an Option and a total match

let shown =
    match findName 9 with
    | Some n -> n.ToUpper()
    | None   -> "unknown"
// val shown : string = "unknown"
// omit the None arm and the compiler reports FS0025: incomplete match

Two helpers fold the match into one call when the shape is routine. Option.defaultValue supplies a fallback for None — the F# answer to ??. Option.map transforms the value inside a Some and leaves None alone — the F# answer to ?.. Both are total, so neither can skip the absent case.

// defaultValue: a fallback for None — like ?? 
Option.defaultValue 0 (Some 5)    // val it : int = 5
findName 9 |> Option.defaultValue "unknown"   // val it : string = "unknown"

// map: transform a Some, leave a None — like ?. 
findName 1 |> Option.map (fun n -> n.ToUpper())   // val it : string option = Some "ADA"
findName 9 |> Option.map (fun n -> n.ToUpper())   // val it : string option = None

Explore findName id

Pick an id. findName looks it up in the fixed map and lands on one of two outcomes: the Some rail, carrying the name, when the id is a key; the None rail, carrying nothing, when it is not. The readout shows the exact FSI value, and — below it — what the caller's match must return for that case. The None arm is never optional.

findName id · a Some rail and a None rail

id Map.tryFind string option 2 key in map ? Some · / · None Some "Grace" None
id = 2 · findName 2 = Some "Grace" · the match returns "Grace"
Without JavaScript: the same three lookups, by hand
idfindName idthe match returns
2Some "Grace""Grace"
1Some "Ada""Ada"
9None"unknown"

The two rails are one type, read one way. The None rail is not an error and not an exception — it is a value, and the match that reads it cannot pretend it is not there.

C2.03.1 · part 3

Why the compiler can now help

The gain is not a new way to spell null. It is that absence now has a type the compiler tracks. When a value is string option, every use must account for both cases before it can reach the string inside — the language has no operation that pulls the value out of a None. The class of bug that ships as a NullReferenceException in production becomes an incomplete-match warning at the keyboard.

F# can still see a null when it comes from .NET — a C# library may hand one back. That null lives only at the boundary, and dive C2.03.3 converts it to an Option with Option.ofObj the moment it crosses into F#.

C# habit

null for a missing reference, guarded by convention with ?. and ?? — the compiler does not insist.

F# form

Some / None, read by a total match or folded with Option.defaultValue · map.

Where this leads

Option records that a value is missing. When a step can fail and the reason for the failure matters, the answer is a sibling type that carries that reason on its failing side — Result — and that is the next dive. The seam where C#'s null and throws cross into F# is the dive after.

References

Sources

Related in this course