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.
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 'Twhen a value is present,Nonewhen absent. - None
- one value, no payload — the typed stand-in for "missing", with no way to dereference it by accident.
- tryFind
- the
tryprefix marks a function that returns anOptionrather than throwing on a miss. - vs. null
nullis any reference and shows in no type;Noneis a value of a named option type the caller must handle.
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 | findName id | the match returns |
|---|---|---|
| 2 | Some "Grace" | "Grace" |
| 1 | Some "Ada" | "Ada" |
| 9 | None | "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.
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.
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
- Microsoft — F# documentation — options, and the guidance on null in F#.
- FSharp.Core API reference — the
Optionmodule:tryFind's siblings,defaultValue,map,ofObj. - Microsoft — .NET documentation — nullable reference types and the
?./??operators in C#.
Related in this course
- C2.03.2 — From exceptions to Result — the same shape with a reason on the failing side.
- C1.08 — Option, Result & railway-oriented errors —
Optionin language depth. - Elixir — Purity — the BEAM's
{:ok, v}/:error, returned rather than raised.