F# Module 4 – Modelling data with types
Options, value options and nullness
Model missing values in F# with option and value option, turn the nulls that .NET methods return into options, and catch them with F# 9 nullness checks.
What you will learn
- Replace a nested match on options with Option.bind, Option.map, Option.defaultValue and Option.orElse
- Choose between option and value option from what each allocates and copies
- Convert .NET nulls and Nullable values into options at the boundary, and back again
- Switch on F# 9 nullness checking and fix FS3261 warnings with a null pattern
Before you start
What you’ll learn · Before you start
What you will learn
- Replace a nested match on options with Option.bind, Option.map, Option.defaultValue and Option.orElse
- Choose between option and value option from what each allocates and copies
- Convert .NET nulls and Nullable values into options at the boundary, and back again
- Switch on F# 9 nullness checking and fix FS3261 warnings with a null pattern
Before you start
On this page
Of the three customer numbers this lesson looks up, one has an email address, one has none and one does not exist.
In C# both gaps usually arrive as null, and one forgotten check is a NullReferenceException in production. F#
gives a missing value a type of its own, the option: An F# value that is either Some value or None, used instead of null for something that may be missing. Functions such as Option.defaultValue and Option.map handle both cases. More in “From C# to F#: a side-by-side syntax map”: Some value or None, and the compiler
makes every caller say what None means.
You will replace nested matches with the Option module, choose between option and value option from measured allocations (6,024,032 bytes against 32 for the same million lookups), turn the nulls that .NET methods return into options at the edge of your code, and switch on F# 9 nullness checking, which flags a null that plain F# lets through without a word. Every run was recorded on .NET 10.
An option puts the missing case into the type
A customer record can say in its type which data may be missing: Email: string option holds
Some "Asha.Rao@Example.com" or None, so no reader mistakes a missing address for an empty one. A lookup can fail
too, and Map.tryFind says so in its result, an option. lookup.fsx asks for the email address of customers 1, 2 and 3:
// Three customer numbers: one with an email address, one without, and one that does not exist.
type Customer = { Id: int; Name: string; Email: string option }
let customers =
Map [ 1, { Id = 1; Name = "Asha"; Email = Some "Asha.Rao@Example.com" }
2, { Id = 2; Name = "Ravi"; Email = None } ]
// Map.tryFind returns an option: Some customer, or None when the number is not in the map.
let tryCustomer id = Map.tryFind id customers
let contactNested id =
match tryCustomer id with
| None -> "no such customer"
| Some c ->
match c.Email with
| Some email -> email.ToLowerInvariant()
| None -> $"no email for {c.Name}"
for id in [ 1; 2; 3 ] do
printfn "%d: %s" id (contactNested id) from session import fsi, run
# lookup.fsx, then the types F# Interactive gives an option, and a match that forgets None.
run("""
dotnet fsi lookup.fsx
""")
fsi([
'Map.tryFind;;',
'let names = Map [ 1, "Asha"; 2, "Ravi" ];;',
'let nameOf id =\n match Map.tryFind id names with\n | Some name -> name;;',
'nameOf 2;;',
'nameOf 3;;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi lookup.fsx
1: asha.rao@example.com
2: no email for Ravi
3: no such customer
$ dotnet fsi --nologo
> Map.tryFind;;
val it: ('a -> Map<'a,'b> -> 'b option) when 'a: comparison
> let names = Map [ 1, "Asha"; 2, "Ravi" ];;
val names: Map<int,string> = map [(1, "Asha"); (2, "Ravi")]
> let nameOf id =
- match Map.tryFind id names with
- | Some name -> name;;
match Map.tryFind id names with
----------^^^^^^^^^^^^^^^^^^^^
stdin(4,11): warning FS0025: Incomplete pattern matches on this expression. For example, the value 'None' may indicate a case not covered by the pattern(s).
val nameOf: id: int -> string
> nameOf 2;;
val it: string = "Ravi"
> nameOf 3;;
Microsoft.FSharp.Core.MatchFailureException: The match cases were incomplete
at FSI_0004.nameOf(Int32 id) in stdin:line 5
at <StartupCode$FSI_0006>.$FSI_0006.main@() in stdin:line 7
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Stopped due to error
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookup_session.py
The three answers come from three different rules: Asha has an address, Ravi has a record without one, and number 3 is not a customer at all. Step through them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Customer 1. tryCustomer 1 finds Asha, so Map.tryFind returns Some with her record and the first match takes the Some c rule.
- Asha has an email. Her Email field is Some "Asha.Rao@Example.com", so the inner match takes Some email and lowers its case.
- The first answer. contactNested 1 is asha.rao@example.com, the first line lookup.fsx printed.
- Customer 2. tryCustomer 2 finds Ravi, so the first match takes Some c again. His record exists, but its Email field is None.
- A missing email. The inner None rule answers no email for Ravi. The record type made the gap visible: Email is a string option, not a string.
- Customer 3. Map.tryFind 3 finds nothing and returns None, so the outer None rule answers no such customer and the inner match never runs.
F# Interactive printed the library’s own promise: Map.tryFind returns 'b option. An option is a union of two
cases, so a match over it is checked like any union. nameOf handled only Some, and the compiler warned FS0025 and
named the value it had missed, None. That warning is the point of the type: nameOf 2 worked, and nameOf 3 stopped
with MatchFailureException, the kind of crash that a forgotten null check gives in C#, except that here the compiler
pointed at it first.
Silencing FS0025 with a wildcard
Wrong: | _ -> "" after the Some rule, which hides a missing customer behind an empty string that later code takes
for a real name. Right: a None rule that says what a missing customer means, as contactNested does with “no such
customer”.
Checkpoint
Check: Options and the check for None
The Option module replaces the nested match
The nested match works, but it grows a level with every optional step. Each function of the Option module handles one
step and passes a None straight on, so the same question becomes a pipeline. pipeline.fsx asks it that way, then
compares two ways of supplying a default:
// lookup.fsx's question as a pipeline of Option functions, with no nested match.
type Customer = { Id: int; Name: string; Email: string option }
let customers =
Map [ 1, { Id = 1; Name = "Asha"; Email = Some "Asha.Rao@Example.com" }
2, { Id = 2; Name = "Ravi"; Email = None } ]
let tryCustomer id = Map.tryFind id customers
let contact id =
tryCustomer id
|> Option.bind (fun c -> c.Email)
|> Option.map (fun email -> email.ToLowerInvariant())
|> Option.defaultValue "no email on file"
for id in [ 1; 2; 3 ] do
printfn "%d: %s" id (contact id)
// A default that takes work to build: defaultValue builds it first, defaultWith only when the option is None.
let supportAddress () =
printfn " (building the support address)"
"support@example.com"
printfn "defaultValue on Some:"
let a = Some "asha.rao@example.com" |> Option.defaultValue (supportAddress ())
printfn "defaultWith on Some:"
let b = Some "asha.rao@example.com" |> Option.defaultWith supportAddress
printfn "defaultWith on None:"
let c = None |> Option.defaultWith supportAddress
printfn "%s | %s | %s" a b c
// The first source that has a value wins.
let fromCommandLine: string option = None
let fromEnvironment = Some "INR"
let fromFile = Some "USD"
printfn "currency: %A" (fromCommandLine |> Option.orElse fromEnvironment |> Option.orElse fromFile) from session import fsi, run
# pipeline.fsx, then the signatures of the four Option functions it uses.
run("""
dotnet fsi pipeline.fsx
""")
fsi([
'Option.map;;',
'Option.bind;;',
'Option.defaultValue;;',
'Option.orElse;;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi pipeline.fsx
1: asha.rao@example.com
2: no email on file
3: no email on file
defaultValue on Some:
(building the support address)
defaultWith on Some:
defaultWith on None:
(building the support address)
asha.rao@example.com | asha.rao@example.com | support@example.com
currency: Some "INR"
$ dotnet fsi --nologo
> Option.map;;
val it: (('a -> 'b) -> 'a option -> 'b option)
> Option.bind;;
val it: (('a -> 'b option) -> 'a option -> 'b option)
> Option.defaultValue;;
val it: ('a -> 'a option -> 'a)
> Option.orElse;;
val it: ('a option -> 'a option -> 'a option)
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline_session.py
The signatures F# Interactive printed say what each function does:
Option.maptakes a function'a -> 'band applies it inside aSome; aNonestaysNone.Option.bindtakes a function that itself returns an option,'a -> 'b option, and does not wrap its result again.fun c -> c.Emailis such a function, sobindgives astring optionwheremapwould give astring option option.Option.defaultValuetakes the value out of aSomeand gives its default forNone, so the result is a plain value again.Option.orElsekeeps the first option that isSome: the currency came from the environment because the command line had none.
Step through customer 2, then customer 1:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Ravi is found. tryCustomer 2 returns Some with Ravi's record, the same first step as the nested match.
- bind takes his Email. Option.bind calls the function on Ravi's record. The function returns his Email field, which is None, and bind returns that None without wrapping it again.
- map skips a None. Option.map has nothing to lower, so it passes the None on and never calls ToLowerInvariant.
- The default. Option.defaultValue turns the None into "no email on file", the second line pipeline.fsx printed.
- Asha is found. Now contact 1. tryCustomer 1 returns Some with Asha's record, and bind returns her Email field, Some "Asha.Rao@Example.com".
- map changes the value. Option.map calls ToLowerInvariant on the text inside the Some and wraps the result in a new Some.
- The value comes out. Option.defaultValue returns the value inside the Some, so contact 1 is asha.rao@example.com, the same answer as the nested match.
The pipeline gave customers 2 and 3 the same answer, “no email on file”, where the nested match said why. Merging the
two reasons is fine for a contact line on an invoice. When the caller must tell them apart, keep the match, or return a
Result, which carries the reason with it; the track’s module on errors and validation teaches it.
A default that does work, given to defaultValue
Wrong: Option.defaultValue (supportAddress ()), which builds the default before it looks at the option: the run
printed “(building the support address)” for a Some that never needed it. Right: Option.defaultWith supportAddress, which calls the function only for None, as the third case shows.
Option.orElse has a lazy twin for the same reason, Option.orElseWith, which builds its fallback option only when
the first one is None.
Key idea
Use map for a function that returns a plain value and bind for one that returns an option, and leave the option
once, at the end, with defaultValue, or with defaultWith when the default takes work to build.
Checkpoint
Check: Option.map, bind and the defaults
A value option stores its answer inline, with no object
Every Some of an option is an object on the heap, which the garbage collector later has to find and reclaim. A
value option: The struct form of an option, written voption, with the cases ValueSome and ValueNone. It is stored where the variable is, so creating one allocates nothing on the heap, but the whole value is copied wherever it goes., written voption, has the cases ValueSome and ValueNone and is a
struct, stored where the variable is. allocation.fsx gives a million orders 5 % cashback from 750 rupees up, once with
each type, and counts the bytes the thread allocated:
open System
open System.Globalization
// 5 % cashback on orders of 750 rupees or more: a million orders of 1 to 1,000 rupees, each amount a thousand times.
let tryCashback amount = if amount >= 750 then Some(amount / 20) else None
let tryCashbackV amount = if amount >= 750 then ValueSome(amount / 20) else ValueNone
let withOption () =
let mutable total = 0L
for i in 0..999_999 do
match tryCashback (i % 1000 + 1) with
| Some c -> total <- total + int64 c
| None -> ()
total
let withValueOption () =
let mutable total = 0L
for i in 0..999_999 do
match tryCashbackV (i % 1000 + 1) with
| ValueSome c -> total <- total + int64 c
| ValueNone -> ()
total
// Bytes this thread allocated while f ran, and f's result.
let allocated f =
let before = GC.GetAllocatedBytesForCurrentThread()
let result = f ()
GC.GetAllocatedBytesForCurrentThread() - before, result
// One run of each first, so the measured runs include no first-call work.
withOption () |> ignore
withValueOption () |> ignore
let optionBytes, optionTotal = allocated withOption
let valueBytes, valueTotal = allocated withValueOption
let n (x: int64) = x.ToString("N0", CultureInfo.InvariantCulture)
printfn "option: %10s bytes allocated, cashback %s rupees" (n optionBytes) (n optionTotal)
printfn "value option: %10s bytes allocated, cashback %s rupees" (n valueBytes) (n valueTotal)
// What a variable or an array element of each type holds.
printfn "size: int option %d, int voption %d, float voption %d, decimal voption %d bytes"
sizeof<int option> sizeof<int voption> sizeof<float voption> sizeof<decimal voption>
// The ValueOption module mirrors the Option module, and the two convert into each other.
printfn "%A %A" (tryCashbackV 800 |> ValueOption.map (fun c -> c * 2)) (tryCashbackV 100 |> ValueOption.defaultValue 0)
printfn "%A %A" (Option.toValueOption (tryCashback 900)) (Option.ofValueOption (tryCashbackV 900)) from session import run
# allocation.fsx counts the bytes a million lookups allocate with an option and with a value option.
run("""
dotnet fsi allocation.fsx
""") Output
$ dotnet fsi allocation.fsx option: 6,024,032 bytes allocated, cashback 10,860,000 rupees value option: 32 bytes allocated, cashback 10,860,000 rupees size: int option 8, int voption 8, float voption 16, decimal voption 24 bytes ValueSome 80 0 ValueSome 45 Some 45
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 allocation_session.py
251 amounts in every thousand qualify, so the loop made 251,000 Some values: 6,024,032 bytes, which is 24 bytes each
plus the 32 bytes that both runs allocated. The value option run allocated only those 32 bytes for the same
10,860,000 rupees of cashback. The cost moved rather than vanished: a value option is copied whole wherever it goes, 8
bytes for an int voption, 16 for a float voption and 24 for a decimal voption, where an option is always one
8-byte reference. The F#
coding conventions suggest a struct for data of 16 bytes or less that is made at a high rate, and say to measure
before deciding. Answer the questions for a value you are modelling:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Type | Allocates per Some | Bytes copied | Library functions |
|---|---|---|---|
| option Written: Some x or None | Poor an object each: 6,024,032 bytes for 251,000 Somes | Good a reference, 8 bytes, whatever the value | Good what Map.tryFind and List.tryFind return |
| value option Written: ValueSome x or ValueNone | Good none: the cashback run allocated 32 bytes in all | Mixed the whole value: 8 bytes for an int, 24 for a decimal | Mixed the ValueOption module; convert with Option.toValueOption |
How the answers decide
- “Is the value made millions of times on a path that must stay fast?” Yes, a hot path: adds a caveat to option. Each Some is an object of 24 bytes: 251,000 of them allocated 6,024,032 bytes, against 32 for the value option.
- “Is the value made millions of times on a path that must stay fast?” No, now and then: adds a caveat to value option. Off the hot path the allocation is too small to matter, and option reads the same as the rest of the library.
- “Is the value inside larger than 16 bytes, such as a struct record of several fields?” Yes, larger than 16 bytes: adds a caveat to value option. A value option is copied whole on every pass and return. The F# conventions suggest a struct for data of 16 bytes or less.
- “Does it come from, or go to, library functions that use option?” Yes, Map.tryFind and friends: adds a caveat to value option. Map.tryFind, List.tryFind and Option.ofObj return option, so a value option needs a conversion at each of them.
The ValueOption module has functions to match the Option module’s, ValueOption.map and ValueOption.defaultValue
among them, and Option.toValueOption and Option.ofValueOption convert where the two meet. F# 10 also lets an
optional parameter of a method be a value option: mark the parameter [<Struct>].
Checkpoint
Check: Value options and what they cost
Nulls from .NET become options at the boundary
F# types never hold null on their own, but .NET methods return it: Environment.GetEnvironmentVariable returns null
for a variable that is not set, as its documentation says. Convert once, where the value enters your code, and use
options from there on. boundary.fsx does it for three common shapes:
open System
open System.Collections.Generic
// A .NET method that returns null: this variable is not set in the session.
let raw = Environment.GetEnvironmentVariable "SHOP_REGION"
printfn "GetEnvironmentVariable returned null: %b" (isNull raw)
printfn "Some raw: %A" (Some raw)
printfn "Option.ofObj raw: %A" (Option.ofObj raw)
// TryGetValue reports a missing key with false and an out value; F# returns both as a tuple.
let settings = Dictionary<string, string>()
settings["currency"] <- "INR"
let tryRead key =
match settings.TryGetValue key with
| true, value -> Some value
| false, _ -> None
printfn "tryRead: %A %A" (tryRead "currency") (tryRead "locale")
// Nullable<int>, a C# int?, converts with Option.ofNullable and back with Option.toNullable.
printfn "Option.ofNullable: %A %A" (Option.ofNullable (Nullable<int>())) (Option.ofNullable (Nullable 500))
printfn "Option.toNullable: %b" (Option.toNullable (None: int option)).HasValue
// The way back for a reference type: None becomes null.
printfn "Option.toObj None is null: %b" (isNull (Option.toObj (None: string option)))
// None is stored as null, so a caller outside F# sees null.
printfn "None is null at run time: %b" (obj.ReferenceEquals((None: int option), null))
try
printfn "%d" (None: int option).Value
with e -> printfn ".Value on None: %s" (e.GetType().Name)
try
printfn "%d" (Option.get (None: int option))
with e -> printfn "Option.get on None: %s" (e.GetType().Name) from session import run
# boundary.fsx turns the nulls of three .NET APIs into options, and back.
run("""
dotnet fsi boundary.fsx
""") Output
$ dotnet fsi boundary.fsx GetEnvironmentVariable returned null: true Some raw: Some null Option.ofObj raw: None tryRead: Some "INR" None Option.ofNullable: None Some 500 Option.toNullable: false Option.toObj None is null: true None is null at run time: true .Value on None: NullReferenceException Option.get on None: ArgumentException
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 boundary_session.py
Step through the conversions, left to right and back:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A method returns null. SHOP_REGION is not set, so Environment.GetEnvironmentVariable returned null, which its documentation promises for a variable that does not exist.
- Some wraps the null. Some raw printed Some null: Some wraps whatever it is given, null included, so the null is still there inside the option.
- ofObj turns null into None. Option.ofObj raw printed None. It returns None for null and Some for anything else, so the code after it cannot meet a null.
- A key that is missing. TryGetValue reports a missing key with false. F# returns the result and the out value as a tuple, so a match on (true, value) gives Some "INR" and None for locale.
- An empty Nullable. A Nullable<int> is a struct, not a null reference. Option.ofNullable turned an empty one into None and Nullable 500 into Some 500.
- The way back. Option.toObj turns None back into null for .NET code that expects one, and Option.toNullable gives an empty Nullable.
- None is null inside. F# stores None as null: obj.ReferenceEquals(None, null) printed true, and .Value on None threw NullReferenceException. A C# caller of an F# option sees exactly that null.
Some raw printed Some null: wrapping is not converting, and the null inside would still throw later. Option.ofObj
converts, and it is what the F# coding conventions use for a .NET result that may be null. The false of
TryGetValue and an empty Nullable: System.Nullable<'T>, a .NET struct that wraps a value type such as int with a HasValue flag so that it can be empty; C# writes it int?. Option.ofNullable and Option.toNullable convert it. became None the same way.
The last lines show the trap in the other direction. F# stores None as null, so .Value on it threw
NullReferenceException, and C# code that calls an F# function returning string option receives null for every
None. A public API meant for C# returns a type that C# already understands, such as a string that may be null or a
TryGet method with an out value, and keeps its options inside.
Option.get and .Value are the null check you meant to avoid
Both fail on None, Option.get with ArgumentException and .Value with NullReferenceException, so code that
calls them crashes just as unchecked null does. Match instead, or use defaultValue.
Checkpoint
Check: Turning .NET nulls into options
F# 9 nullness checking shows where null can still arrive
Without help, the compiler does not track null: a string from .NET has the plain type string, which may hold null,
and nothing warns when it is used unchecked. F# 9 added
nullness checking: F# 9's opt-in checks for null, switched on with <Nullable>enable</Nullable> in a project or dotnet fsi --checknulls+. A type that may hold null is written string | null, and an unchecked use warns FS3261., which is off by default: <Nullable>enable</Nullable> in a
project file switches it on by passing the --checknulls+ option to the compiler, and a script gets the same with
dotnet fsi --checknulls+. The session types the same entries without the option and with it, then builds a project
that switches it on in its project file:
open System
// Not set when the program runs, so the method returns null.
let region = Environment.GetEnvironmentVariable "SHOP_REGION"
let shout (text: string) = text.ToUpperInvariant()
let shoutSafe (text: string | null) =
match text with
| null -> "(not set)"
| t -> t.ToUpperInvariant()
printfn "%s" (shoutSafe region)
printfn "%s" (shout region) <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<!-- F# 9 nullness checking: the compiler's checknulls option. -->
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
</Project> from session import fsi, run
# The same entries without and with nullness checking, then a project that switches it on in its project file.
fsi([
'let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;',
'let shout (text: string) = text.ToUpperInvariant();;',
'shout region;;',
'let size (text: string | null) = text.Length;;',
'#quit;;',
], args=["--nologo"])
fsi([
'let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;',
'let shout (text: string) = text.ToUpperInvariant();;',
'shout region;;',
'let shoutSafe (text: string | null) =\n match text with\n | null -> "(not set)"\n | t -> t.ToUpperInvariant();;',
'shoutSafe region;;',
'let code: string = region |> Option.ofObj |> Option.defaultValue "IN";;',
'#quit;;',
], args=["--nologo", "--checknulls+"])
run("""
dotnet build nullcheck -v q
""") Output
$ dotnet fsi --nologo
> let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;
val region: string = <null>
> let shout (text: string) = text.ToUpperInvariant();;
val shout: text: string -> string
> shout region;;
System.NullReferenceException: Object reference not set to an instance of an object.
at <StartupCode$FSI_0004>.$FSI_0004.main@() in stdin:line 3
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Stopped due to error
> let size (text: string | null) = text.Length;;
val size: text: (string | null) -> int
> #quit;;
$ dotnet fsi --nologo --checknulls+
> let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;
val region: string | null = <null>
> let shout (text: string) = text.ToUpperInvariant();;
val shout: text: string -> string
> shout region;;
shout region;;
------^^^^^^
stdin(3,7): warning FS3261: Nullness warning: A non-nullable 'string' was expected but this expression is nullable. Consider either changing the target to also be nullable, or use pattern matching to safely handle the null case of this expression.
System.NullReferenceException: Object reference not set to an instance of an object.
at <StartupCode$FSI_0004>.$FSI_0004.main@() in stdin:line 3
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Stopped due to error
> let shoutSafe (text: string | null) =
- match text with
- | null -> "(not set)"
- | t -> t.ToUpperInvariant();;
val shoutSafe: text: (string | null) -> string
> shoutSafe region;;
val it: string = "(not set)"
> let code: string = region |> Option.ofObj |> Option.defaultValue "IN";;
val code: string = "IN"
> #quit;;
$ dotnet build nullcheck -v q
nullcheck/Program.fs(14,21): warning FS3261: Nullness warning: A non-nullable 'string' was expected but this expression is nullable. Consider either changing the target to also be nullable, or use pattern matching to safely handle the null case of this expression. [nullcheck/NullCheck.fsproj]
Build succeeded.
nullcheck/Program.fs(14,21): warning FS3261: Nullness warning: A non-nullable 'string' was expected but this expression is nullable. Consider either changing the target to also be nullable, or use pattern matching to safely handle the null case of this expression. [nullcheck/NullCheck.fsproj]
1 Warning(s)
0 Error(s)
Time Elapsed …
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nullness_session.py
Without checking, F# Interactive gave region the type string and the value <null>, shout region failed at run
time with no warning, and even size, which reads Length from a string | null, compiled without one. With
checking, the same method’s result had the type string | null, because .NET marks it as possibly null, and
shout region drew warning FS3261: a non-nullable string was expected. The warning did not stop the call, which
still threw, but it pointed at the line before anything ran, and the build printed the same FS3261 for line 14 of
Program.fs. The fix is a match that takes the null case first:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- A nullable value. With checking on, F# Interactive gave region the type string | null, because .NET marks GetEnvironmentVariable's result as possibly null.
- The null rule. region is null, so the first rule fits and shoutSafe returns "(not set)" without calling a method on the null.
- The type narrows. The second rule runs only for values that are not null, so the compiler gives t the type string, and t.ToUpperInvariant() gets no warning.
- Without the match. shout region passed the string | null where a string was expected. The compiler warned FS3261, the call still ran, and it threw NullReferenceException.
After | null -> the compiler knows that the rest of the match sees no null, so t has the type string and needs
no check. Option.ofObj takes a string | null as well, which makes it the usual way out of the nullable world. The
F# reference adds a rule of style: | null belongs at the edges where .NET code meets yours, and options stay the way
to model a value that may be missing.
Nullness checking, F# 9 and later
The string | null syntax and the FS3261 warnings came with F# 9 and .NET 9. A project or script that does not
switch the checks on still compiles the syntax and checks nothing, as size showed in the first session.
Checkpoint
Check: F# 9 nullness checking
Practise: settings from three sources
practice.fsx resolves a setting the way many programs do: the command line first, then an environment variable, then a settings file, then a default. Its file lookup is written in the .NET style and returns null for a missing key, and the script runs with nullness checking on:
open System
open System.Collections.Generic
// A settings file read into a dictionary, and a lookup in the .NET style: null for a key that is missing.
let fileSettings = Dictionary<string, string>()
fileSettings["currency"] <- "INR"
fileSettings["locale"] <- ""
let readSetting (key: string) : string | null =
match fileSettings.TryGetValue key with
| true, value -> value
| false, _ -> null
// At the boundary the null becomes None, and nothing after it sees a null.
let tryReadSetting key = readSetting key |> Option.ofObj
// An empty value is present, but this program treats it as not set.
let nonEmpty (value: string) =
if String.IsNullOrWhiteSpace value then None else Some value
// The command line first, then the environment (SHOP_CURRENCY …), then the file, then the default.
let resolve (commandLine: string option) (key: string) fallback =
commandLine
|> Option.orElse (Environment.GetEnvironmentVariable $"SHOP_{key.ToUpperInvariant()}" |> Option.ofObj)
|> Option.orElse (tryReadSetting key |> Option.bind nonEmpty)
|> Option.defaultValue fallback
printfn "in the file: %A %A %A" (tryReadSetting "currency") (tryReadSetting "locale") (tryReadSetting "timezone")
printfn "currency: %s" (resolve None "currency" "USD")
printfn "currency: %s" (resolve (Some "EUR") "currency" "USD")
printfn "locale: %s" (resolve None "locale" "en-IN")
printfn "timezone: %s" (resolve None "timezone" "UTC") from session import run
# practice.fsx with nullness checking on, once as it is and once with the currency set in the environment.
run("""
dotnet fsi --checknulls+ practice.fsx
SHOP_CURRENCY=GBP dotnet fsi --checknulls+ practice.fsx
""") Output
$ dotnet fsi --checknulls+ practice.fsx in the file: Some "INR" Some "" None currency: INR currency: EUR locale: en-IN timezone: UTC $ SHOP_CURRENCY=GBP dotnet fsi --checknulls+ practice.fsx in the file: Some "INR" Some "" None currency: GBP currency: EUR locale: en-IN timezone: UTC
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
The checks found nothing to warn about, because the null never leaves tryReadSetting. The file’s locale is present
but empty, Some "", which nonEmpty treats as not set, so locale fell back to en-IN, and with SHOP_CURRENCY=GBP in
the environment the second run took GBP ahead of the file’s INR. An explicit Some "EUR" from the command line won in
both runs.
Tell an empty value from a missing one
Replace Option.bind nonEmpty with Option.bind Some and run dotnet fsi --checknulls+ practice.fsx again: locale
becomes the empty string from the file, while timezone, which the file does not have, still falls back to UTC.
Test the five ideas of this lesson below. Each answer is explained as soon as you pick it, and the questions turn first to whichever idea you have answered least well so far:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up, for freshers and 2–5 years: how do option and value option differ in F#, and when does each fit?
Both say that a value may be missing, with the same matches and the same module functions. An option is a reference
type: every Some is an object on the heap, and None is stored as null. A value option, voption, is a struct with
the cases ValueSome and ValueNone, so creating one allocates nothing, but the whole value is copied wherever it
goes. Use option by default, because the library returns it (Map.tryFind, List.tryFind, Option.ofObj) and
the copies stay small. Use a value option on a hot path that creates many small results, such as a parser or a
lookup called millions of times, after measuring: in this lesson a million cashback lookups allocated 6,024,032 bytes
with option, an object for each of its 251,000 results, and 32 with value option.
Two harder questions follow, about an F# library whose None reached C# callers as null, and about switching on
nullness checking in a large service:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An F# library exposes tryFindCustomer: string -> Customer option. A C# service calls it, reads .Value and crashes with NullReferenceException whenever no customer is found, while every F# test passes. Explain why, and design the API so that both F# and C# callers handle a missing customer safely.
-
Senior to staff
Your team wants F# 9 nullness checking in a service of 180 F# files that calls many .NET libraries. A trial build with <Nullable>enable</Nullable> reports hundreds of FS3261 warnings. How do you roll the checks out without stopping feature work, and which warnings do you fix first?
Key takeaways
- An option puts a missing value into the type, so a match that forgets
Noneis warning FS0025 instead of a crash found later. Option.bind,Option.mapandOption.defaultValuereplace nested matches, anddefaultWithandorElseWithbuild their fallback only when it is needed.- A value option needs no object for its result, so a million cashback lookups allocated 32 bytes with it against 6,024,032 with option, but it is copied whole and suits small values on hot paths.
- Convert .NET nulls once, at the boundary, with
Option.ofObj,Option.ofNullableor a match onTryGetValue, and remember thatNonereaches C# callers as null. - F# 9 nullness checking gives .NET results the type
string | nulland warns FS3261 where they are used unchecked, and a null pattern orOption.ofObjfixes the warning.
Terms in this lesson
- Nullable
- System.Nullable<'T>, a .NET struct that wraps a value type such as int with a HasValue flag so that it can be empty; C# writes it int?. Option.ofNullable and Option.toNullable convert it.
- nullness checking
- F# 9's opt-in checks for null, switched on with <Nullable>enable</Nullable> in a project or dotnet fsi --checknulls+. A type that may hold null is written string | null, and an unchecked use warns FS3261.
- value option
- The struct form of an option, written voption, with the cases ValueSome and ValueNone. It is stored where the variable is, so creating one allocates nothing on the heap, but the whole value is copied wherever it goes.
References
- Options (Microsoft Learn)
- Value options (Microsoft Learn)
- Null values (and nullable reference types from F# 9) (Microsoft Learn)
- Nullable value types (Microsoft Learn)
- Option module (FSharp.Core) (The F# Software Foundation)
- What's new in F# 9 (nullable reference types) (Microsoft Learn)
- F# RFC FS-1060: nullable reference types (F# Software Foundation (fslang-design))
- What's new in F# 10 (ValueOption optional parameters) (Microsoft Learn)
- F# compiler options (--checknulls) (Microsoft Learn)
- F# coding conventions (nulls and default values, structs for small types) (Microsoft Learn)
- Environment.GetEnvironmentVariable method (Microsoft Learn)
- Dictionary.TryGetValue method (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress